Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/compaction.rs
T
雷电芽衣 d1c29a576c fix(compaction): failures carry the real error, with endpoint credentials redacted
The pager rendered "Compaction failed." with the error dropped, and the
shell's unsuppressed auto-compaction failure sent an empty error. Both
now carry the decoded first line of the failure (retry guidance plus the
detail), decoded from the ACP data rather than its JSON-escaped Display,
propagated unchanged through the compact extension, capped for a banner.

Endpoint credentials never reach the banner: the sampler redacts the
parsed endpoint (userinfo, sensitive query values, fragment via the
repository sanitizer) in every 401 message and the appended request URL,
and the shell redacts every URL in the error text before line selection,
including URLs whose password holds whitespace or that follow a
multi-byte delimiter.

Ported from upstream 1.0.13 (grok-build dispatch/prompt.rs,
session/compaction.rs), extended with the redaction.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 12:12:00 -04:00

3327 lines
149 KiB
Rust

//! Compaction methods for `SessionActor`.
//!
//! These form a second `impl SessionActor` block alongside the primary one in
//! `acp_session.rs`.
use super::SessionActor;
use super::is_project_instructions;
use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
use crate::session::compaction_config::{
AsyncCompactionCache, SUPPRESS_NONE, SUPPRESS_STICKY, SUPPRESS_TURN, SUPPRESS_UNTIL_SUCCESS,
};
use crate::session::helpers::CompactionStateContext;
use crate::session::helpers::compaction_context::CompactionInputs;
use crate::session::helpers::compaction_context::to_system_reminder;
use crate::session::helpers::session_compact::{
CompactOutput, CompactionOutcome, build_compaction_chat_history,
build_two_pass_compaction_prompt, generate_session_compact, is_context_length_error,
};
use crate::session::persistence::PersistenceMsg;
use crate::session::two_pass::{
TWO_PASS_DEFAULT_SPLIT_FRACTION, build_two_pass_pass1_history, build_two_pass_pass2_history,
note_for_two_pass_pass2, split_conversation_for_two_pass,
};
use agent_client_protocol as acp;
use kigi_chat_state::compaction_utils::{
CompactedHistoryInput, CompactionAttempt, build_compacted_history, is_degenerate_summary,
prepare_conversation_for_verbatim_summarization, sanitize_compacted_history,
validate_compacted_history,
};
use kigi_sampling_types::{ApiBackend, ConversationItem};
use std::sync::Arc;
/// What initiated a compaction: the user (`/compact`) or the auto-compaction
/// threshold. Drives span labels, the `PreCompact` hook `source`, and the
/// persisted request artifact.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CompactionTrigger {
Manual,
Auto,
}
/// Default percentage points below the auto-compact threshold at which prefire
/// (background pass-1) starts, giving pass-1 runway to finish before the limit.
/// Override with `KIGI_PREFIRE_LEAD_PERCENT`.
const DEFAULT_PREFIRE_LEAD_PERCENT: u64 = 10;
fn prefire_lead_percent() -> u64 {
std::env::var("KIGI_PREFIRE_LEAD_PERCENT")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(DEFAULT_PREFIRE_LEAD_PERCENT)
}
/// Cheap fingerprint of a conversation prefix for prefire NOTE₁ validity. A
/// mismatch means the prefix changed (edit / rewind / branch) since pass-1, so
/// the cached NOTE₁ no longer summarizes the current prefix and must be dropped.
fn fingerprint_prefix(items: &[ConversationItem]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
items.len().hash(&mut h);
for it in items {
let tag: u8 = match it {
ConversationItem::System(_) => 0,
ConversationItem::User(_) => 1,
ConversationItem::Assistant(_) => 2,
ConversationItem::ToolResult(_) => 3,
ConversationItem::BackendToolCall(_) => 4,
ConversationItem::Reasoning(_) => 5,
};
tag.hash(&mut h);
it.text_content().hash(&mut h);
}
h.finish()
}
/// Outcome of a background prefire pass-1 run, recorded on the
/// `session.prefire_pass1` span as `compaction_prefire_outcome`.
/// [`PrefireOutcome::as_str`] values are stable telemetry keys
/// (telemetry/dashboards key off them) — don't rename the strings.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum PrefireOutcome {
Cached,
Disabled,
DebugFailPass1,
TooSmall,
EmptySplit,
SampleFailed,
EmptyNote1,
}
impl PrefireOutcome {
fn as_str(self) -> &'static str {
match self {
Self::Cached => "cached",
Self::Disabled => "disabled",
Self::DebugFailPass1 => "debug_fail_pass1",
Self::TooSmall => "too_small",
Self::EmptySplit => "empty_split",
Self::SampleFailed => "sample_failed",
Self::EmptyNote1 => "empty_note1",
}
}
}
/// Telemetry from one prefire pass-1 run; recorded onto the
/// `session.prefire_pass1` span by [`SessionActor::run_prefire_pass1`].
/// `None` fields = the run exited before that stage.
struct PrefirePass1Run {
outcome: PrefireOutcome,
prefix_len: Option<usize>,
prefix_est_tokens: Option<u64>,
pass1_latency_ms: Option<u64>,
note1_chars: Option<usize>,
}
impl From<PrefireOutcome> for PrefirePass1Run {
/// A run that exited before splitting/sampling — outcome only.
fn from(outcome: PrefireOutcome) -> Self {
Self {
outcome,
prefix_len: None,
prefix_est_tokens: None,
pass1_latency_ms: None,
note1_chars: None,
}
}
}
#[cfg(test)]
mod two_pass_prefire_helper_tests {
use super::{fingerprint_prefix, prefire_lead_percent};
use kigi_sampling_types::ConversationItem;
#[test]
fn fingerprint_stable_for_same_prefix() {
let items = vec![
ConversationItem::system("sys"),
ConversationItem::user("hello"),
ConversationItem::assistant("hi"),
];
assert_eq!(fingerprint_prefix(&items), fingerprint_prefix(&items));
}
#[test]
fn fingerprint_changes_when_prefix_content_changes() {
let base = vec![
ConversationItem::system("sys"),
ConversationItem::user("hello"),
];
let edited = vec![
ConversationItem::system("sys"),
ConversationItem::user("HELLO there"),
];
assert_ne!(
fingerprint_prefix(&base),
fingerprint_prefix(&edited),
"a changed prefix must invalidate the cached NOTE1 fingerprint"
);
}
#[test]
fn fingerprint_changes_with_length() {
let short = vec![ConversationItem::user("a")];
let long = vec![
ConversationItem::user("a"),
ConversationItem::assistant("b"),
];
assert_ne!(fingerprint_prefix(&short), fingerprint_prefix(&long));
}
#[test]
fn prefire_lead_percent_defaults_to_10() {
unsafe { std::env::remove_var("KIGI_PREFIRE_LEAD_PERCENT") };
assert_eq!(prefire_lead_percent(), 10);
}
}
impl SessionActor {
/// Two-pass active for this session: flag resolved on at build AND not an
/// agent that keeps its single short self-summary.
pub(crate) fn two_pass_active(&self) -> bool {
let agent = self.agent.borrow();
agent.compaction_policy().two_pass_enabled
}
/// Run one summarization sample over a fully-built two-pass history (the
/// prompt is already embedded, so this bypasses the single-pass sampler and
/// calls `generate_session_compact` directly). Returns `None` on any error
/// so callers fall back to single-pass.
///
/// Agent `RefCell` borrows are only taken for synchronous snapshots (never
/// held across `.await`). Prefire is `spawn_local` on the same LocalSet as
/// the turn loop; a long-lived borrow would race with turn/compact/cancel
/// and panic on double-borrow.
async fn two_pass_sample(&self, history: Vec<ConversationItem>) -> Option<CompactOutput> {
let sampling_config = self.reconstruct_full_config().await;
let client = match self.prepare_chat_completion(false).await {
Ok(c) => c,
Err(e) => {
tracing::warn!(
error = % e, "two_pass: failed to prepare sampling client"
);
return None;
}
};
let tool_defs = self.prepare_tool_definitions().await;
let tools = self.turn_base_tool_specs(&tool_defs);
let (hosted_tools, wall_clock_budget_secs) = {
let agent = self.agent.borrow();
let use_backend_search =
agent.backend_search_enabled() && self.supports_backend_search.get();
(
if use_backend_search {
agent.hosted_tools().to_vec()
} else {
Vec::new()
},
agent.compaction_policy().wall_clock_budget_secs,
)
};
match generate_session_compact(
history,
tools,
hosted_tools,
client,
self.session_info.id.clone(),
&sampling_config,
self.inference_idle_timeout,
wall_clock_budget_secs,
self.compaction.tool_choice,
)
.await
{
Ok(out) => Some(out),
Err(e) => {
tracing::warn!(error = ? e, "two_pass: summarization sample failed");
None
}
}
}
/// Per-turn prefire decision: usage has reached `threshold - lead` (so there
/// is still runway before the hard auto-compact line at `threshold`).
pub(crate) async fn should_prefire_two_pass(&self) -> bool {
let sampling_cfg = self.chat_state_handle.get_sampling_config().await;
let Some(cw) = sampling_cfg.as_ref().map(|c| c.context_window.get()) else {
return false;
};
let estimated_total = self.chat_state_handle.get_estimated_total_tokens().await;
let threshold = self.compaction.threshold_percent.get() as u64;
let start_pct = threshold.saturating_sub(prefire_lead_percent());
kigi_token_estimation::exceeds_threshold(estimated_total, cw, start_pct as u8)
}
/// Background pass-1: summarize the ~95% prefix → NOTE₁ and cache it for a
/// later pass-2 apply. Always releases the in-flight guard. Spawned via
/// `spawn_local` from the turn loop; reads a conversation snapshot and does
/// not mutate session state. The span makes speculative pass-1 spend
/// measurable (hit rate, wasted input tokens) ahead of the fleet-wide ramp.
#[tracing::instrument(
name = "session.prefire_pass1",
skip_all,
fields(
session_id = %self.session_info.id.0,
compaction_prefire_outcome = tracing::field::Empty,
compaction_pass1_latency_ms = tracing::field::Empty,
compaction_prefire_prefix_len = tracing::field::Empty,
compaction_prefire_prefix_est_tokens = tracing::field::Empty,
compaction_prefire_note1_chars = tracing::field::Empty,
)
)]
pub(crate) async fn run_prefire_pass1(self: &Arc<Self>) {
struct InFlightGuard<'a>(&'a crate::session::compaction_config::PrefireState);
impl Drop for InFlightGuard<'_> {
fn drop(&mut self) {
self.0.finish();
}
}
let _guard = InFlightGuard(&self.compaction.prefire);
let run = self.run_prefire_pass1_inner().await;
let span = tracing::Span::current();
span.record("compaction_prefire_outcome", run.outcome.as_str());
if let Some(v) = run.prefix_len {
span.record("compaction_prefire_prefix_len", v as i64);
}
if let Some(v) = run.prefix_est_tokens {
span.record("compaction_prefire_prefix_est_tokens", v as i64);
}
if let Some(v) = run.pass1_latency_ms {
span.record("compaction_pass1_latency_ms", v as i64);
}
if let Some(v) = run.note1_chars {
span.record("compaction_prefire_note1_chars", v as i64);
}
}
async fn run_prefire_pass1_inner(self: &Arc<Self>) -> PrefirePass1Run {
if !self.two_pass_active() {
return PrefireOutcome::Disabled.into();
}
if std::env::var("KIGI_DEBUG_TWO_PASS_FAIL_PASS1")
.is_ok_and(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
{
tracing::info!(
target : "two_pass",
"two_pass: DEBUG KIGI_DEBUG_TWO_PASS_FAIL_PASS1 — prefire pass1 produces no cache"
);
return PrefireOutcome::DebugFailPass1.into();
}
let conversation = self.chat_state_handle.get_conversation().await;
if conversation.len() < 4 {
return PrefireOutcome::TooSmall.into();
}
let split = split_conversation_for_two_pass(&conversation, TWO_PASS_DEFAULT_SPLIT_FRACTION);
if split.prefix.is_empty() || split.tail.is_empty() {
return PrefireOutcome::EmptySplit.into();
}
let sampling_cfg = self.chat_state_handle.get_sampling_config().await;
let strips = sampling_cfg
.as_ref()
.map(|c| c.api_backend == ApiBackend::Messages)
.unwrap_or(false);
let model_slug = sampling_cfg
.as_ref()
.map(|c| c.model.to_string())
.unwrap_or_default();
let prefix_prepared =
prepare_conversation_for_verbatim_summarization(split.prefix.to_vec(), strips);
let prefix_est_tokens = prefix_prepared
.iter()
.map(kigi_chat_state::estimate_item_tokens)
.sum::<u64>();
let prompt = build_two_pass_compaction_prompt(None);
let pass1_history = build_two_pass_pass1_history(&prefix_prepared, &prompt);
let started = std::time::Instant::now();
let out = self.two_pass_sample(pass1_history).await;
let pass1_latency_ms = started.elapsed().as_millis() as u64;
let attempted = |outcome: PrefireOutcome, note1_chars: Option<usize>| PrefirePass1Run {
outcome,
prefix_len: Some(split.split_idx),
prefix_est_tokens: Some(prefix_est_tokens),
pass1_latency_ms: Some(pass1_latency_ms),
note1_chars,
};
let Some(out) = out else {
return attempted(PrefireOutcome::SampleFailed, None);
};
let note1 = note_for_two_pass_pass2(&out.content);
if note1.trim().is_empty() {
return attempted(PrefireOutcome::EmptyNote1, None);
}
let note1_chars = note1.chars().count();
let cache = AsyncCompactionCache {
note1,
prefix_len: split.split_idx,
fingerprint: fingerprint_prefix(&conversation[..split.split_idx]),
model_slug,
pass1_latency_ms,
};
tracing::info!(
target : "two_pass", prefix_len = cache.prefix_len, pass1_latency_ms = cache
.pass1_latency_ms, "two_pass: prefire pass1 cached NOTE1"
);
self.compaction.prefire.store(cache);
attempted(PrefireOutcome::Cached, Some(note1_chars))
}
/// Pass-2 apply: if a valid cached NOTE₁ exists for the current conversation,
/// summarize (NOTE₁ + recent tail + special prompt) → final summary and
/// return its `CompactOutput`. `None` → caller runs the single-pass path.
///
/// **telemetry / `session.compact_inner` latency:** the returned `CompactOutput`
/// stream timings are what land on `compaction_ttft_ms` /
/// `compaction_stream_ms`. Those reflect **user-visible sync wait only**:
/// - background pass-1 that already finished before compact is *not*
/// included (prefire hid that cost);
/// - if pass-1 is still in flight we **do** add that await into
/// `ttft_ms` (time until first token of the final summary), because the
/// user is blocked on it;
/// - `stream_ms` / `delta_count` / `itl_max_ms` are always pass-2 only
/// (the only sample that streams the successor-visible summary).
async fn try_two_pass_pass2_apply(
&self,
user_context: Option<&str>,
strips_reasoning: bool,
) -> Option<CompactOutput> {
if !self.two_pass_active() {
return None;
}
let mut prefire_waited_ms = 0u64;
if let Some(handle) = self.compaction.prefire.take_handle() {
let was_in_flight = self.compaction.prefire.is_in_flight();
let waited = std::time::Instant::now();
let _ = handle.await;
if was_in_flight {
prefire_waited_ms = waited.elapsed().as_millis() as u64;
tracing::Span::current()
.record("compaction_prefire_waited_ms", prefire_waited_ms as i64);
tracing::info!(
target : "two_pass", wait_ms = prefire_waited_ms,
"two_pass: waited for in-flight prefire pass1 before pass2"
);
}
}
let cache = self.compaction.prefire.take()?;
let live = self.chat_state_handle.get_conversation().await;
let model_slug = self
.chat_state_handle
.get_sampling_config()
.await
.map(|c| c.model.to_string())
.unwrap_or_default();
if cache.prefix_len == 0
|| cache.prefix_len > live.len()
|| cache.model_slug != model_slug
|| fingerprint_prefix(&live[..cache.prefix_len]) != cache.fingerprint
{
tracing::Span::current().record("compaction_prefire_stale", true);
tracing::info!(
target : "two_pass",
"two_pass: cached NOTE1 stale or model changed; falling back to single-pass"
);
return None;
}
let prefix = &live[..cache.prefix_len];
let tail = &live[cache.prefix_len..];
let prepared_tail =
prepare_conversation_for_verbatim_summarization(tail.to_vec(), strips_reasoning);
let prompt = build_two_pass_compaction_prompt(user_context);
let pass2_history =
build_two_pass_pass2_history(prefix, &prepared_tail, &cache.note1, &prompt);
let started = std::time::Instant::now();
let mut out = self.two_pass_sample(pass2_history).await?;
if is_degenerate_summary(&out.content) {
tracing::Span::current().record("compaction_prefire_stale", true);
tracing::info!(
target : "two_pass",
"two_pass: pass2 summary empty/degenerate; falling back to single-pass"
);
return None;
}
let pass2_latency_ms = started.elapsed().as_millis() as u64;
if prefire_waited_ms > 0 {
out.ttft_ms = Some(out.ttft_ms.unwrap_or(0).saturating_add(prefire_waited_ms));
}
let span = tracing::Span::current();
span.record("compaction_two_pass_used", true);
span.record("compaction_prefire_hit", true);
span.record("compaction_pass2_latency_ms", pass2_latency_ms as i64);
tracing::info!(
target : "two_pass", prefix_len = cache.prefix_len, tail_len = tail.len(),
prefire_waited_ms, pass2_latency_ms, pass1_bg_latency_ms = cache
.pass1_latency_ms, "two_pass: pass2 applied cached NOTE1 (prefire hit)"
);
Some(out)
}
}
pub(crate) struct AutoCompactTriggerInfo {
pub tokens_used: u64,
pub context_window: u64,
pub percentage: u8,
}
/// Why auto-compaction was suppressed after a deterministic failure.
/// [`SuppressReason::as_str`] is a stable telemetry value (BQ/OTLP/dashboards key
/// off it) — don't rename the strings.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum SuppressReason {
CreditBlock,
Size,
Auth,
Schema,
Other,
}
impl SuppressReason {
fn as_str(self) -> &'static str {
match self {
SuppressReason::CreditBlock => "credit_block",
SuppressReason::Size => "size",
SuppressReason::Auth => "auth",
SuppressReason::Schema => "schema",
SuppressReason::Other => "other",
}
}
/// Suppression scope for this reason:
/// - `size | schema` → [`SUPPRESS_STICKY`]: retrying the same conversation
/// can't help; cleared only on a context-budget change.
/// - `credit_block | auth` → [`SUPPRESS_UNTIL_SUCCESS`]: re-sending fails the
/// same way every turn until the user acts, so don't clear per-turn — wait
/// for an actual successful model call (a `200` proves recovery).
/// - `other` → [`SUPPRESS_TURN`]: optimistic per-turn retry.
fn suppress_state(self) -> u8 {
match self {
SuppressReason::Size | SuppressReason::Schema => SUPPRESS_STICKY,
SuppressReason::CreditBlock | SuppressReason::Auth => SUPPRESS_UNTIL_SUCCESS,
SuppressReason::Other => SUPPRESS_TURN,
}
}
}
/// Splice the preserved prefix (`conversation[0..prefix_len]`) onto the compacted
/// suffix, dropping the suffix's leading System and — if the prefix already has an
/// AGENTS.md item — its re-injected AGENTS.md too (else the model sees it twice).
/// Returns `Err(compacted_history)` unchanged when `prefix_len` is 0 or out of range.
fn preserve_inherited_prefix(
conversation: &[ConversationItem],
compacted_history: Vec<ConversationItem>,
prefix_len: usize,
) -> Result<Vec<ConversationItem>, Vec<ConversationItem>> {
if prefix_len == 0 || prefix_len > conversation.len() {
return Err(compacted_history);
}
let inherited = &conversation[..prefix_len];
let drop_reinjected_agents_md = inherited.iter().any(is_project_instructions);
let mut preserved = inherited.to_vec();
let child_items = compacted_history
.into_iter()
.skip_while(|i| matches!(i, ConversationItem::System(_)))
.filter(|i| !(drop_reinjected_agents_md && is_project_instructions(i)));
preserved.extend(child_items);
Ok(preserved)
}
/// Project the token count a re-pinned (preserved) history would reseed to, so the
/// release decision compares against the same threshold the auto-compact trigger
/// applies next turn. This only APPROXIMATES the compaction reseed
/// (`kigi-chat-state` `replace_conversation`, the authority): it matches the reseed's
/// round-and-cap but divides by the current conversation estimate, not the reseed's
/// frozen `estimate_at_last_response`. The conversation only grows, so the current
/// estimate is >= that frozen value; this therefore under-estimates the reseed (a
/// lower bound) and can lean toward preserve. That never re-loops: the post-replace
/// `exceeds_threshold` check on the real reseeded total still sets sticky Size
/// suppression if a preserve leaves the fork over budget.
fn project_preserved_reseed_tokens(
preserved_estimate: u64,
tokens_before: u64,
full_conv_estimate: u64,
) -> u64 {
let ratio = tokens_before as f64 / full_conv_estimate.max(1) as f64;
((preserved_estimate as f64 * ratio).round() as u64).min(tokens_before)
}
impl SessionActor {
/// Path to the raw `updates.jsonl` transcript if it exists, else `None`.
/// `pub(crate)` so the `Transcript`-mode dispatch in `compaction_segments`
/// and transcript-location pointers can both reuse it.
///
/// The `path.exists()` guard keeps the pointer safe when a session (e.g. a
/// nested sub-agent) never wrote one -- the hint is simply omitted rather
/// than dangling.
pub(crate) fn get_transcript_path(&self) -> Option<String> {
let path =
crate::session::persistence::session_dir(&self.session_info).join("updates.jsonl");
if path.exists() {
Some(path.to_string_lossy().into_owned())
} else {
None
}
}
/// Increment the compaction counter and launch a pre-compaction memory flush.
///
/// The counter is incremented before the flush check so the once-per-cycle
/// guard does not suppress the first eligible flush.
async fn maybe_pre_compaction_flush(
self: &Arc<Self>,
total_tokens: u64,
context_window: u64,
trigger: &'static str,
) {
let compaction_count = self
.compaction
.count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
if !self.agent.borrow().compaction_policy().memory_flush_enabled {
return;
}
let last_flush = self
.memory
.last_flush_compaction
.load(std::sync::atomic::Ordering::Relaxed);
if crate::session::helpers::memory_flush::should_flush(
total_tokens,
context_window,
self.compaction.threshold_percent.get(),
&self.memory.flush_config,
last_flush,
compaction_count,
) {
let snapshot = self.snapshot_memory_flush_state().await;
tokio::task::spawn_local({
let session = self.clone();
async move {
if session.run_memory_flush(trigger, Some(snapshot)).await {
session
.memory
.last_flush_compaction
.store(compaction_count, std::sync::atomic::Ordering::Relaxed);
}
}
});
}
}
/// Tag the current `session.compact` span with `mode` (and `detail`, for
/// `segments`) — the A/B variant key for grouping outcomes in telemetry.
fn record_compaction_variant(&self) {
let mode = self.compaction.compaction_mode;
let span = tracing::Span::current();
span.record("mode", tracing::field::display(mode));
if let Some(detail) = mode.segment_detail() {
span.record("detail", tracing::field::display(detail));
}
}
#[tracing::instrument(
name = "session.compact",
skip_all,
fields(
session_id = %self.session_info.id.0,
trigger = "manual",
mode = tracing::field::Empty,
detail = tracing::field::Empty,
pre_tokens = tracing::field::Empty,
post_tokens = tracing::field::Empty,
success = tracing::field::Empty,
error = tracing::field::Empty,
)
)]
pub(crate) async fn run_compact(
self: &Arc<Self>,
user_context: Option<String>,
) -> Result<(), acp::Error> {
self.record_compaction_variant();
let total_tokens = self.chat_state_handle.get_total_tokens().await;
tracing::Span::current().record("pre_tokens", total_tokens as i64);
let sampling_config = self.chat_state_handle.get_sampling_config().await;
let context_window = sampling_config
.as_ref()
.map(|c| c.context_window.get())
.unwrap_or(DEFAULT_CONTEXT_WINDOW);
self.maybe_pre_compaction_flush(total_tokens, context_window, "pre_compaction")
.await;
if let Err(e) = self
.run_compact_inner(user_context, None, CompactionTrigger::Manual)
.await
{
let span = tracing::Span::current();
span.record("success", false);
span.record("error", e.to_string().as_str());
return Err(e);
}
use crate::extensions::notification::SessionUpdate as XaiSessionUpdate;
let tokens_after = self.chat_state_handle.get_total_tokens().await;
let span = tracing::Span::current();
span.record("post_tokens", tokens_after as i64);
span.record("success", true);
self.send_xai_notification(XaiSessionUpdate::AutoCompactCompleted {
tokens_before: Some(total_tokens),
tokens_after,
elapsed_ms: None,
summary_preview: None,
})
.await;
Ok(())
}
/// Suppress AUTO compaction after a deterministic failure so the gates stop
/// re-firing a doomed compaction. Scope depends on the reason (see
/// [`SuppressReason::suppress_state`]): size/schema are sticky, credit/auth
/// hold until a model call succeeds, other clears next turn. Fires telemetry +
/// one notification per transition; manual `/compact` is exempt.
async fn suppress_auto_compaction(
&self,
reason: SuppressReason,
estimated_tokens: u64,
context_window: u64,
) {
let new_state = reason.suppress_state();
if self
.compaction
.auto_compact_suppressed
.compare_exchange(
SUPPRESS_NONE,
new_state,
std::sync::atomic::Ordering::Relaxed,
std::sync::atomic::Ordering::Relaxed,
)
.is_ok()
{
tracing::warn!(
suppress_reason = reason.as_str(),
estimated_tokens,
context_window,
"auto-compaction suppressed after deterministic compaction failure"
);
let message = match reason {
SuppressReason::CreditBlock => {
"out of credits or over your spending limit. Add credits and retry."
}
SuppressReason::Auth => {
"authentication problem — re-authenticate using /login and retry."
}
SuppressReason::Size => "this conversation is too large to compact.",
SuppressReason::Schema => "this conversation can't be summarized.",
SuppressReason::Other => {
"it'll retry on the next turn, or start a new session using /new."
}
};
self.send_xai_notification(
crate::extensions::notification::SessionUpdate::AutoCompactFailed {
error: message.to_string(),
},
)
.await;
}
}
/// Retry guidance headline, then the user-facing detail on its own line.
fn failure_with_retry_guidance(err: &acp::Error) -> String {
const RETRY_GUIDANCE: &str =
"it'll retry on the next turn, or start a new session using /new.";
let detail = crate::sampling::error::user_facing_compact_error(
&crate::sampling::error::acp_error_message(err),
);
if detail.is_empty() {
RETRY_GUIDANCE.to_string()
} else {
format!("{RETRY_GUIDANCE}\n{detail}")
}
}
/// Map a deterministic failure's error text to a fixed, content-free
/// [`SuppressReason`] (drives telemetry + sticky-vs-per-turn scope).
fn classify_suppress_reason(error_msg: &str) -> SuppressReason {
let m = error_msg.to_ascii_lowercase();
if m.contains("spending-limit")
|| m.contains("spending limit")
|| m.contains("out of credits")
|| m.contains("usage balance exhausted")
|| m.contains("usage limit reached")
{
SuppressReason::CreditBlock
} else if is_context_length_error(&m) {
SuppressReason::Size
} else if m.contains("status 401") || m.contains("unauthorized") {
SuppressReason::Auth
} else if m.contains("invalid_request_error") {
SuppressReason::Schema
} else {
SuppressReason::Other
}
}
/// Choose the post-compaction history for a forked session: re-pin the inherited
/// prefix, or release it (fall back to the self-contained summary the summarizer
/// already built from the whole conversation) when re-pinning would leave the fork
/// at/over the auto-compact threshold. On release, sets the sticky flag and records
/// the release span field (this runs within the `run_compact_inner` span).
///
/// This runtime release compensates for a verbatim mirror-fork that pinned its whole
/// parent transcript; bounding the inherited prefix at fork admission is the
/// structural alternative that would remove this path.
async fn resolve_forked_compacted_history(
&self,
compacted_history: Vec<ConversationItem>,
prefix_len: usize,
tokens_before: u64,
context_window: u64,
) -> Vec<ConversationItem> {
let full_conv = self.chat_state_handle.get_conversation().await;
let compacted_len = compacted_history.len();
let release_candidate = compacted_history.clone();
match preserve_inherited_prefix(&full_conv, compacted_history, prefix_len) {
Ok(preserved) => {
let projected_preserved = project_preserved_reseed_tokens(
kigi_chat_state::estimate_conversation_tokens(&preserved),
tokens_before,
kigi_chat_state::estimate_conversation_tokens(&full_conv),
);
if kigi_token_estimation::exceeds_threshold(
projected_preserved,
context_window,
self.compaction.threshold_percent.get(),
) {
self.compaction
.prefix_released
.store(true, std::sync::atomic::Ordering::Relaxed);
tracing::Span::current().record("compaction_prefix_released", true);
tracing::info!(
session_id = % self.session_info.id.0, prefix_len,
projected_preserved,
"compaction: releasing inherited prefix under pressure"
);
release_candidate
} else {
tracing::info!(
session_id = % self.session_info.id.0, prefix_len, compacted_len,
"Preserving inherited prefix across compaction"
);
preserved
}
}
Err(original) => {
tracing::warn!(
session_id = % self.session_info.id.0, prefix_len, conversation_len =
full_conv.len(),
"Inherited prefix invalid, using compacted history as-is"
);
original
}
}
}
/// Inner implementation of compaction that supports an optional `auto_continue`
/// payload for the checkpoint.
#[tracing::instrument(
name = "session.compact_inner",
skip_all,
fields(
session_id = %self.session_info.id.0,
compaction_tokens_before = tracing::field::Empty,
compaction_tokens_after = tracing::field::Empty,
compaction_summary_chars = tracing::field::Empty,
compaction_degenerate_rejections = tracing::field::Empty,
compaction_input_overflow_rejections = tracing::field::Empty,
compaction_deterministic_rejections = tracing::field::Empty,
compaction_transient_rejections = tracing::field::Empty,
compaction_attempts = tracing::field::Empty,
compaction_trigger = tracing::field::Empty,
compaction_trigger_pct = tracing::field::Empty,
compaction_threshold_pct = tracing::field::Empty,
compaction_outcome = tracing::field::Empty,
compaction_stop_reason = tracing::field::Empty,
compaction_ttft_ms = tracing::field::Empty,
compaction_stream_ms = tracing::field::Empty,
compaction_delta_count = tracing::field::Empty,
compaction_itl_max_ms = tracing::field::Empty,
compaction_two_pass_used = tracing::field::Empty,
compaction_prefire_hit = tracing::field::Empty,
compaction_pass2_latency_ms = tracing::field::Empty,
compaction_prefire_waited_ms = tracing::field::Empty,
compaction_prefire_stale = tracing::field::Empty,
compaction_prefix_released = tracing::field::Empty,
)
)]
async fn run_compact_inner(
&self,
user_context: Option<String>,
auto_continue: Option<crate::extensions::notification::AutoContinueInfo>,
trigger: CompactionTrigger,
) -> Result<(), acp::Error> {
let tokens_before = self.chat_state_handle.get_total_tokens().await;
tracing::Span::current().record("compaction_tokens_before", tokens_before as i64);
self.signals_handle().record_compaction(tokens_before);
let trigger_str = match trigger {
CompactionTrigger::Manual => "manual",
CompactionTrigger::Auto => "auto",
};
let sampling_config = self.chat_state_handle.get_sampling_config().await;
let context_window = sampling_config
.as_ref()
.map(|c| c.context_window.get())
.unwrap_or(DEFAULT_CONTEXT_WINDOW);
{
let span = tracing::Span::current();
let trigger_pct = if context_window == 0 {
0
} else {
((tokens_before as f64 / context_window as f64) * 100.0).round() as i64
};
span.record("compaction_trigger_pct", trigger_pct);
span.record(
"compaction_threshold_pct",
self.compaction.threshold_percent.get() as i64,
);
span.record("compaction_trigger", trigger_str);
}
let summary_strips_reasoning = sampling_config
.as_ref()
.map(|c| c.api_backend == ApiBackend::Messages)
.unwrap_or(false);
let model_id = sampling_config.map(|c| c.model).unwrap_or_default();
let compact_source = trigger_str;
self.dispatch_hook(
kigi_hooks::event::HookEventName::PreCompact,
kigi_hooks::event::HookPayload::PreCompact {
source: compact_source.into(),
},
None,
None,
)
.await;
let max_retries = 3u32;
let retry_delay_secs = 3u64;
let (conv_len, system_message, full_conversation) = tokio::join!(
self.chat_state_handle.get_conversation_len(),
self.chat_state_handle.get_system_message(),
self.chat_state_handle.get_conversation(),
);
let segment_messages = if self.compaction.compaction_mode.writes_segments() {
kigi_chat_state::compaction_utils::prepare_conversation_for_segment(
full_conversation.clone(),
)
} else {
Vec::new()
};
const SUMMARY_BUDGET_RESERVE_TOKENS: u64 = 32_768;
let verbatim_input_enabled = self.compaction.verbatim_input;
let simplified_messages = if verbatim_input_enabled {
kigi_chat_state::compaction_utils::prepare_conversation_for_verbatim_summarization(
full_conversation,
summary_strips_reasoning,
)
} else {
kigi_chat_state::compaction_utils::prepare_conversation_for_summarization(
full_conversation,
)
};
if conv_len == 0 {
tracing::error!(
session_id = % self.session_info.id.0,
"Compaction failed: conversation is empty (ChatStateActor may have died)"
);
return Err(
acp::Error::internal_error().data("Compaction failed: conversation is empty")
);
}
let system_message = match system_message {
Some(msg) => msg,
None => {
tracing::error!(
session_id = % self.session_info.id.0, conversation_len = conv_len,
"Compaction failed: no system message in conversation history"
);
return Err(acp::Error::internal_error()
.data("Compaction failed: no system message in conversation history"));
}
};
if simplified_messages.is_empty() {
tracing::error!(
session_id = % self.session_info.id.0, conversation_len = conv_len,
"Compaction failed: simplified conversation is empty"
);
return Err(acp::Error::internal_error()
.data("Compaction failed: simplified conversation is empty"));
}
if !simplified_messages
.iter()
.any(|msg| matches!(msg, ConversationItem::System(_)))
{
tracing::error!(
session_id = % self.session_info.id.0, conversation_len = conv_len,
simplified_len = simplified_messages.len(),
"Compaction failed: no system message in simplified conversation"
);
return Err(acp::Error::internal_error()
.data("Compaction failed: no system message in simplified conversation"));
}
let sampling_config = self.reconstruct_full_config().await;
let sampling_client = self.prepare_chat_completion(false).await?;
let use_backend_search =
self.agent.borrow().backend_search_enabled() && self.supports_backend_search.get();
let effective_tool_defs: Vec<kigi_sampling_types::ToolDefinition> = self
.prepare_tool_definitions()
.await
.into_iter()
.filter(|td| !use_backend_search || td.function.name != "web_search")
.collect();
let compaction_tool_tokens =
kigi_chat_state::estimate_tool_definitions_tokens(&effective_tool_defs);
let compaction_tools: Vec<kigi_sampling_types::ToolSpec> = effective_tool_defs
.into_iter()
.map(kigi_sampling_types::ToolSpec::from)
.collect();
let compaction_hosted_tools: Vec<kigi_sampling_types::HostedTool> = if use_backend_search {
self.agent.borrow().hosted_tools().to_vec()
} else {
Vec::new()
};
tracing::info!(
num_tools = compaction_tools.len(),
tool_tokens = compaction_tool_tokens,
"Running compact with model '{}' (user model: '{}')",
&sampling_config.model,
&sampling_config.model
);
let mut last_error: Option<acp::Error> = None;
let mut last_failure_outcome = CompactionOutcome::Failed;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum InputStage {
Verbatim,
VerbatimFitted,
Lossy,
}
impl InputStage {
fn as_str(self) -> &'static str {
match self {
Self::Verbatim => "verbatim",
Self::VerbatimFitted => "verbatim_fitted",
Self::Lossy => "lossy",
}
}
}
let mut input_stage = if verbatim_input_enabled {
InputStage::Verbatim
} else {
InputStage::Lossy
};
let use_short_prompt = false;
let started_at = chrono::Utc::now().to_rfc3339();
let estimated_input_tokens =
kigi_chat_state::estimate_conversation_tokens(&simplified_messages);
let auto_trigger = matches!(trigger, CompactionTrigger::Auto);
let wall_clock_budget_secs = self
.agent
.borrow()
.compaction_policy()
.wall_clock_budget_secs;
let sampler = crate::session::helpers::full_replace_compaction::ShellCompactionSampler::new(
use_short_prompt,
user_context.clone(),
compaction_tools.clone(),
compaction_hosted_tools.clone(),
sampling_client,
self.session_info.id.clone(),
sampling_config.clone(),
self.inference_idle_timeout,
wall_clock_budget_secs,
self.compaction.tool_choice,
);
let observer =
crate::session::helpers::full_replace_compaction::ShellFullReplaceObserver::new(
self.session_info.id.0.to_string(),
estimated_input_tokens,
retry_delay_secs,
);
let fr_config = kigi_compaction::FullReplaceConfig {
max_attempts: max_retries,
retry_delay_secs,
sampling_timeout_secs: 0,
};
let mut request_turns = simplified_messages.clone();
let mut input_overflow_rejections: u32 = 0;
let two_pass_output = self
.try_two_pass_pass2_apply(user_context.as_deref(), summary_strips_reasoning)
.await;
let mut compact_summary: Option<String> =
two_pass_output.as_ref().map(|o| o.content.clone());
while compact_summary.is_none() {
match kigi_compaction::sample_full_replace_summary(
&sampler,
&request_turns,
user_context.as_deref(),
&fr_config,
&observer,
)
.await
{
Ok(summary) => {
compact_summary = Some(summary.summary);
break;
}
Err(kigi_compaction::FullReplaceError::NothingToCompact) => {
last_error = Some(
acp::Error::internal_error().data("compact failed: nothing to compact"),
);
break;
}
Err(kigi_compaction::FullReplaceError::EmptyResponse) => {
last_failure_outcome = if observer.degenerate_seen() {
CompactionOutcome::Degenerate
} else {
CompactionOutcome::Transient
};
last_error = Some(acp::Error::internal_error().data(
observer.last_error_message().unwrap_or_else(|| {
"compact failed: model returned empty response".to_string()
}),
));
break;
}
Err(kigi_compaction::FullReplaceError::Sampler {
message,
deterministic,
context_overflow,
}) => {
if context_overflow {
let next_stage = match input_stage {
InputStage::Verbatim => Some(InputStage::VerbatimFitted),
InputStage::VerbatimFitted => Some(InputStage::Lossy),
InputStage::Lossy => None,
};
if let Some(stage) = next_stage {
input_overflow_rejections += 1;
tracing::warn!(
session_id = % self.session_info.id.0, ? stage, error = %
message,
"Compaction input overflowed deterministically; stepping down the input ladder to avoid an incompactable state"
);
let conv = self.chat_state_handle.get_conversation().await;
request_turns = match stage {
InputStage::VerbatimFitted => {
let budget = context_window
.saturating_sub(SUMMARY_BUDGET_RESERVE_TOKENS)
.saturating_sub(compaction_tool_tokens);
let verbatim = kigi_chat_state::compaction_utils::prepare_conversation_for_verbatim_summarization(
conv,
summary_strips_reasoning,
);
kigi_chat_state::compaction_utils::fit_conversation_to_budget(
verbatim, budget,
)
}
InputStage::Lossy => {
let lossy_budget = (context_window.saturating_mul(7) / 10)
.saturating_sub(compaction_tool_tokens);
kigi_chat_state::compaction_utils::fit_conversation_to_budget(
kigi_chat_state::compaction_utils::prepare_conversation_for_summarization(
conv,
),
lossy_budget,
)
}
InputStage::Verbatim => {
unreachable!("ladder only steps forward")
}
};
input_stage = stage;
continue;
}
last_failure_outcome = CompactionOutcome::Deterministic;
if auto_trigger {
self.suppress_auto_compaction(
SuppressReason::Size,
estimated_input_tokens,
context_window,
)
.await;
}
last_error = Some(acp::Error::internal_error().data(message));
break;
}
if deterministic {
last_failure_outcome = CompactionOutcome::Deterministic;
if auto_trigger {
let reason = Self::classify_suppress_reason(&message);
self.suppress_auto_compaction(
reason,
estimated_input_tokens,
context_window,
)
.await;
}
last_error = Some(acp::Error::internal_error().data(message));
break;
}
last_failure_outcome = CompactionOutcome::Transient;
last_error = Some(acp::Error::internal_error().data(message));
break;
}
}
}
let telemetry = observer.into_telemetry();
if two_pass_output.is_none() {
let request_chat_history = build_compaction_chat_history(
request_turns,
user_context.as_deref(),
use_short_prompt,
);
self.persist_compaction_request_artifact(
request_chat_history,
compaction_tools,
user_context.as_deref(),
use_short_prompt,
&sampling_config.model,
trigger,
compact_summary
.as_deref()
.or(telemetry.last_rejected_summary.as_deref()),
last_error.as_ref(),
telemetry.attempts,
telemetry.attempt_details,
started_at,
);
}
let compact_output = match compact_summary {
Some(_) => match two_pass_output {
Some(tp) => tp,
None => sampler
.take_last_success()
.expect("a successful full-replace sample stashes its CompactOutput"),
},
None => {
let span = tracing::Span::current();
span.record("compaction_attempts", telemetry.attempts as i64);
span.record(
"compaction_degenerate_rejections",
telemetry.degenerate_rejections as i64,
);
span.record(
"compaction_input_overflow_rejections",
input_overflow_rejections as i64,
);
span.record(
"compaction_deterministic_rejections",
telemetry.deterministic_rejections as i64,
);
span.record(
"compaction_transient_rejections",
telemetry.transient_rejections as i64,
);
span.record("compaction_outcome", last_failure_outcome.as_str());
return Err(last_error.unwrap_or_else(|| {
acp::Error::internal_error().data("compaction failed: unknown error")
}));
}
};
let generate_session_compact = compact_output.content.clone();
let user_message_prefix = self.build_user_message_prefix().await;
let conversation = self.chat_state_handle.get_conversation().await;
let (discovered_agents_md, all_skills_for_compaction, _agent_edited_paths, state_context) =
if use_short_prompt {
let empty_edited: std::collections::BTreeSet<String> = Default::default();
let ctx =
CompactionStateContext::build(&conversation, CompactionInputs::default()).await;
(Vec::<std::path::PathBuf>::new(), vec![], empty_edited, ctx)
} else {
let agents_md: Vec<std::path::PathBuf> = self
.agent
.borrow()
.tool_bridge()
.agents_md_reminded_paths()
.await
.into_iter()
.collect();
let bridge_for_skills = self.agent.borrow().tool_bridge().clone();
let skills = bridge_for_skills.slash_skills().await;
let edited_paths = self.chat_state_handle.get_agent_edited_paths().await;
let ctx = {
let bridge_tasks = self
.agent
.borrow()
.tool_bridge()
.list_background_tasks()
.await;
let pending_tasks: Vec<_> =
bridge_tasks.into_iter().filter(|t| !t.completed).collect();
let (execute_tool_name, monitor_tool_name) = if pending_tasks.is_empty() {
(None, None)
} else {
let agent_ref = self.agent.borrow();
let bridge = agent_ref.tool_bridge();
let empty = serde_json::json!({});
let execute = bridge
.render_prompt("${{ tools.by_kind.execute }}", &empty)
.await
.filter(|s| !s.is_empty() && !s.contains("by_kind"));
let monitor = bridge
.render_prompt("${{ tools.by_kind.monitor }}", &empty)
.await
.filter(|s| !s.is_empty() && !s.contains("by_kind"));
(execute, monitor)
};
let running_tasks: Vec<_> = pending_tasks
.into_iter()
.map(|t| {
let tool_name = match t.kind {
kigi_tools::computer::types::TaskKind::Monitor => {
monitor_tool_name.clone()
}
kigi_tools::computer::types::TaskKind::Bash => {
execute_tool_name.clone()
}
};
CompactionStateContext::task_summary(
t.task_id, t.command, "running", tool_name,
)
})
.collect();
let running_subagents = if let Some(ref event_tx) =
self.tool_context.subagent_event_tx
{
let (tx, rx) = tokio::sync::oneshot::channel();
use kigi_tools::implementations::kigi::task::types::{
SubagentEvent, SubagentListActiveRequest,
};
let _ =
event_tx.send(SubagentEvent::ListActive(SubagentListActiveRequest {
parent_session_id: self.session_id_string(),
respond_to: tx,
}));
rx.await
.unwrap_or_default()
.into_iter()
.map(|s| crate::session::helpers::compaction_context::RunningSubagentSummary {
subagent_id: s.subagent_id,
subagent_type: s.subagent_type,
description: s.description,
elapsed_ms: s.elapsed_ms,
})
.collect()
} else {
vec![]
};
let connected_mcp_servers = {
use crate::session::helpers::compaction_context::CompactionServerSummary;
use kigi_tools::implementations::search_tool::{
sanitize_description, truncate_description,
};
self.connected_server_summaries()
.into_iter()
.map(|s| {
let desc = s
.description
.map(|d| truncate_description(&sanitize_description(&d)))
.filter(|d| !d.is_empty());
CompactionServerSummary {
name: s.name,
tool_count: s.tool_count,
description: desc,
}
})
.collect()
};
let todos = {
use crate::session::helpers::compaction_context::{
TodoSummary, TodoSummaryStatus,
};
use crate::tools::todo::{TodoState, TodoStatus};
use kigi_tools::types::resources::State;
let bridge = self.agent.borrow().tool_bridge().clone();
bridge
.read_resource::<State<TodoState>>()
.await
.map(|s| {
s.0.todo_items_with_ids()
.map(|(id, item)| TodoSummary {
id: id.clone(),
content: item.content.clone(),
status: match item.status {
TodoStatus::Pending => TodoSummaryStatus::Pending,
TodoStatus::InProgress => TodoSummaryStatus::InProgress,
TodoStatus::Completed => TodoSummaryStatus::Completed,
TodoStatus::Cancelled => TodoSummaryStatus::Cancelled,
},
})
.collect()
})
.unwrap_or_default()
};
CompactionStateContext::build(
&conversation,
CompactionInputs {
running_tasks,
running_subagents,
agent_edited_paths: edited_paths.clone(),
connected_mcp_servers,
todos,
},
)
.await
};
(agents_md, skills, edited_paths, ctx)
};
use crate::session::helpers::compaction_context::SubagentToolNames;
let subagent_tool_names: Option<SubagentToolNames> =
if use_short_prompt || state_context.running_subagents.is_empty() {
None
} else {
let agent_ref = self.agent.borrow();
let bridge = agent_ref.tool_bridge();
let empty = serde_json::json!({});
let poll_name = bridge
.render_prompt("${{ tools.by_kind.background_task_action }}", &empty)
.await
.filter(|s| !s.is_empty() && !s.contains("by_kind"));
let cancel_name = bridge
.render_prompt("${{ tools.by_kind.kill_task_action }}", &empty)
.await
.filter(|s| !s.is_empty() && !s.contains("by_kind"));
match (poll_name, cancel_name) {
(Some(poll), Some(cancel)) => Some(SubagentToolNames { poll, cancel }),
(poll, cancel) => {
tracing::warn!(
session_id = % self.session_info.id.0, poll_resolved = poll
.is_some(), cancel_resolved = cancel.is_some(),
"could not resolve subagent tool names, \
omitting subagent reminder from compacted conversation"
);
None
}
}
};
use crate::session::helpers::compaction_context::McpToolNames;
let mcp_tool_names: Option<McpToolNames> =
if use_short_prompt || state_context.connected_mcp_servers.is_empty() {
None
} else {
let agent_ref = self.agent.borrow();
let bridge = agent_ref.tool_bridge();
let empty = serde_json::json!({});
let search_name = bridge
.render_prompt("${{ tools.by_kind.search_tool }}", &empty)
.await
.filter(|s| !s.is_empty() && !s.contains("by_kind"));
let call_name = bridge
.render_prompt("${{ tools.by_kind.use_tool }}", &empty)
.await
.filter(|s| !s.is_empty() && !s.contains("by_kind"));
match (search_name, call_name) {
(Some(search), Some(call)) => Some(McpToolNames { search, call }),
_ => None,
}
};
let memory_backend_impl = {
let g = self.memory.storage.borrow();
g.as_ref()
.zip(self.memory.backend_params.as_ref())
.map(|(storage, params)| {
crate::session::memory::MemoryBackendImpl::from_session_params(
storage.clone(),
&crate::session::memory::MemoryBackendParams {
search_source: "compaction_recovery",
..params.clone()
},
)
})
};
let memory_opt_out = false;
let memory_ref: Option<&dyn kigi_tools::types::memory_backend::MemoryBackend> =
if memory_opt_out {
None
} else {
memory_backend_impl
.as_ref()
.map(|b| b as &dyn kigi_tools::types::memory_backend::MemoryBackend)
};
let suppress_state_reminder = false;
let system_reminder = if suppress_state_reminder {
None
} else {
to_system_reminder(
&state_context,
&discovered_agents_md,
&all_skills_for_compaction,
memory_ref,
subagent_tool_names.as_ref(),
mcp_tool_names.as_ref(),
)
.await
};
let system_reminder = {
let plan_path = {
let guard = self.plan_mode.lock();
guard
.is_active()
.then(|| guard.plan_file_path().to_path_buf())
};
if let Some(plan_path) = plan_path {
let plan_has_content =
crate::session::plan_mode::plan_file_has_content(&plan_path).await;
let template = crate::session::plan_mode::plan_mode_reminder_full_template();
let wrapper = self.reminder_wrapper_tag();
let rendered = self
.render_plan_template(template, &plan_path, plan_has_content)
.await;
match (system_reminder, rendered) {
(Some(mut existing), Some(plan_section)) => {
if let Some(pos) = existing.rfind("</system-reminder>") {
existing.insert_str(pos, &format!("\n\n{}\n", plan_section));
} else {
existing.push_str("\n\n");
existing.push_str(&plan_section);
}
Some(existing)
}
(None, Some(plan_section)) => Some(format!(
"<{tag}>\n{body}\n</{tag}>",
tag = wrapper,
body = plan_section,
)),
(existing, None) => {
tracing::warn!(
session_id = % self.session_info.id.0,
"compaction: plan mode active but template render failed"
);
existing
}
}
} else {
system_reminder
}
};
if let Some(ref recovery_backend) = memory_backend_impl {
let n = recovery_backend
.search_counter
.load(std::sync::atomic::Ordering::Relaxed);
if n > 0 {
self.memory
.compaction_recovery_count
.fetch_add(n, std::sync::atomic::Ordering::Relaxed);
tracing::debug!(
target : kigi_log::memory_log::TARGET, count = n,
"MEMORY_COMPACTION_RECOVERY: {} search(es) performed", n,
);
}
}
let agents_md_reminder = self.agent.borrow().agents_md_user_reminder();
let compaction_context = state_context.for_compaction();
let compaction_state_context: &CompactionStateContext = &compaction_context;
self.persist_compaction_segment(&segment_messages, &generate_session_compact);
let transcript_hint = self.transcript_hint();
let summary_count = self
.compaction
.count
.load(std::sync::atomic::Ordering::Relaxed);
let raw_compacted = build_compacted_history(CompactedHistoryInput {
system_message: system_message.clone(),
user_message_prefix: user_message_prefix.clone(),
agents_md_reminder: agents_md_reminder.clone(),
state_context: compaction_state_context,
compaction_summary: generate_session_compact.clone(),
system_reminder: system_reminder.clone(),
summary_before_recent: use_short_prompt,
transcript_hint: transcript_hint.clone(),
summary_count,
});
let sanitize_result = sanitize_compacted_history(raw_compacted);
let compacted_history = if sanitize_result.stripped_tool_call_ids.is_empty() {
sanitize_result.items
} else {
tracing::warn!(
session_id = % self.session_info.id, stripped_count = sanitize_result
.stripped_tool_call_ids.len(), stripped_ids = ? sanitize_result
.stripped_tool_call_ids,
"compaction: stripped orphaned ToolResults from compacted history"
);
sanitize_result.items
};
let remaining_violations = validate_compacted_history(&compacted_history);
let compacted_history = if remaining_violations.is_empty() {
compacted_history
} else {
tracing::error!(
session_id = % self.session_info.id, violation_count =
remaining_violations.len(), violation_ids = ? remaining_violations,
"compaction: sanitized history still has invalid ToolResults -- \
falling back to minimal compacted history (no recent_messages)"
);
build_compacted_history(CompactedHistoryInput {
system_message,
user_message_prefix,
agents_md_reminder,
state_context: &state_context.for_compaction(),
compaction_summary: generate_session_compact,
system_reminder,
summary_before_recent: use_short_prompt,
transcript_hint,
summary_count,
})
};
let prompt_index_at_compaction = self.chat_state_handle.get_prompt_index().await;
self.chat_state_handle
.record_compaction_at(prompt_index_at_compaction);
let original_user_info = self
.chat_state_handle
.get_conversation_item_at(1)
.await
.and_then(|item| match item {
ConversationItem::User(parts) => {
parts.content.into_iter().next().and_then(|p| match p {
kigi_sampling_types::ContentPart::Text { text } => {
Some(text.as_ref().to_owned())
}
_ => None,
})
}
_ => None,
});
self.persist_compaction_checkpoint(
&compacted_history,
prompt_index_at_compaction,
auto_continue,
original_user_info,
);
let prefix_len = if self
.compaction
.prefix_released
.load(std::sync::atomic::Ordering::Relaxed)
{
0
} else {
self.startup_hints.inherited_prefix_len.unwrap_or(0)
};
let compacted_history = if prefix_len == 0 {
compacted_history
} else {
self.resolve_forked_compacted_history(
compacted_history,
prefix_len,
tokens_before,
context_window,
)
.await
};
let new_len = compacted_history.len();
self.chat_state_handle
.replace_conversation_for_compaction(compacted_history);
if self.startup_hints.inherited_prefix_len.is_some() {
let post_replace_tokens = self.chat_state_handle.get_total_tokens().await;
if kigi_token_estimation::exceeds_threshold(
post_replace_tokens,
context_window,
self.compaction.threshold_percent.get(),
) {
self.compaction
.auto_compact_suppressed
.store(SUPPRESS_STICKY, std::sync::atomic::Ordering::Relaxed);
tracing::warn!(
session_id = % self.session_info.id.0, post_replace_tokens,
context_window,
"compaction: released history still over threshold; suppressing AUTO to avoid a re-loop"
);
} else {
self.compaction
.auto_compact_suppressed
.store(SUPPRESS_NONE, std::sync::atomic::Ordering::Relaxed);
}
} else {
self.compaction
.auto_compact_suppressed
.store(SUPPRESS_NONE, std::sync::atomic::Ordering::Relaxed);
}
self.last_idle_flush_conversation_len
.store(new_len, std::sync::atomic::Ordering::Relaxed);
self.memory
.context_injected
.store(false, std::sync::atomic::Ordering::Relaxed);
if self.memory.is_enabled() {
tracing::info!(
target : kigi_log::memory_log::TARGET,
"MEMORY_COMPACT: post-compaction reset, next turn re-checks injection (search only if no block persisted)"
);
}
let _ = self
.notifications
.persistence_tx
.send(PersistenceMsg::PlanState(
crate::tools::todo::TodoState::default(),
));
self.agent
.borrow()
.tool_bridge()
.on_agents_md_compaction()
.await;
self.agent
.borrow()
.tool_bridge()
.on_skill_discovery_compaction()
.await;
self.persist_announcement_state().await;
self.plan_mode.lock().reset_after_compaction();
self.persist_plan_mode_state();
self.dispatch_hook(
kigi_hooks::event::HookEventName::PostCompact,
kigi_hooks::event::HookPayload::PostCompact {
source: compact_source.into(),
},
None,
None,
)
.await;
let tokens_after = self.chat_state_handle.get_total_tokens().await;
{
let span = tracing::Span::current();
span.record("compaction_tokens_after", tokens_after as i64);
span.record(
"compaction_summary_chars",
compact_output.content.chars().count() as i64,
);
span.record("compaction_attempts", telemetry.attempts as i64);
span.record(
"compaction_degenerate_rejections",
telemetry.degenerate_rejections as i64,
);
span.record(
"compaction_input_overflow_rejections",
input_overflow_rejections as i64,
);
span.record(
"compaction_deterministic_rejections",
telemetry.deterministic_rejections as i64,
);
span.record(
"compaction_transient_rejections",
telemetry.transient_rejections as i64,
);
let stop_reason = compact_output.stop_reason.as_deref().unwrap_or("stop");
span.record("compaction_stop_reason", stop_reason);
let outcome = if compact_output.truncated {
CompactionOutcome::Truncated
} else {
CompactionOutcome::Success
};
span.record("compaction_outcome", outcome.as_str());
span.record("compaction_delta_count", compact_output.delta_count as i64);
if let Some(ms) = compact_output.ttft_ms {
span.record("compaction_ttft_ms", ms as i64);
}
if let Some(ms) = compact_output.stream_ms {
span.record("compaction_stream_ms", ms as i64);
}
if let Some(ms) = compact_output.itl_max_ms {
span.record("compaction_itl_max_ms", ms as i64);
}
}
Ok(())
}
pub(crate) fn should_auto_compact(
&self,
total_tokens: u64,
context_window: std::num::NonZeroU64,
) -> Option<AutoCompactTriggerInfo> {
let cw = context_window.get();
if kigi_token_estimation::exceeds_threshold(
total_tokens,
cw,
self.compaction.threshold_percent.get(),
) {
let percentage = kigi_token_estimation::usage_percentage_u8(total_tokens, cw);
Some(AutoCompactTriggerInfo {
tokens_used: total_tokens,
context_window: cw,
percentage,
})
} else {
None
}
}
/// Returns true if the error response indicates tokens exceed the
/// model's context window. Inspects only the model-metadata
/// portion of the [`SamplingErrorInfo`] (the `context_window`
/// field) against the session's tracked token estimate.
///
/// Called from `handle_sampling_failure` with the
/// `SamplingErrorInfo` the sampler hands back.
pub(crate) async fn should_compact_on_error(
&self,
err: &kigi_sampler::SamplingErrorInfo,
) -> bool {
if self
.compaction
.auto_compact_suppressed
.load(std::sync::atomic::Ordering::Relaxed)
!= SUPPRESS_NONE
{
return false;
}
let Some(ref metadata) = err.model_metadata else {
return false;
};
let Some(context_window) = metadata.context_window else {
return false;
};
if context_window == 0 {
return false;
}
let estimated_total = self.chat_state_handle.get_estimated_total_tokens().await;
estimated_total > context_window
}
/// Pre-sampling compaction check. Uses `get_estimated_total_tokens()`
/// (exact prior count + byte-estimate of items since last response) so
/// tool results are accounted for. Returns `None` when `is_flushing`.
pub(crate) async fn check_auto_compact_needed(&self) -> Option<AutoCompactTriggerInfo> {
if self
.memory
.is_flushing
.load(std::sync::atomic::Ordering::Relaxed)
{
return None;
}
let sampling_cfg = self.chat_state_handle.get_sampling_config().await;
let context_window = sampling_cfg.as_ref().map(|c| c.context_window)?;
let cw = context_window.get();
let model = sampling_cfg
.as_ref()
.map(|c| c.model.clone())
.unwrap_or_default();
let estimated_total = self.chat_state_handle.get_estimated_total_tokens().await;
self.signals_handle()
.update_context_usage(estimated_total, cw);
if self
.compaction
.auto_compact_suppressed
.load(std::sync::atomic::Ordering::Relaxed)
!= SUPPRESS_NONE
{
return None;
}
if self
.compaction
.force_compact
.compare_exchange(
true,
false,
std::sync::atomic::Ordering::Relaxed,
std::sync::atomic::Ordering::Relaxed,
)
.is_ok()
{
let percentage = kigi_token_estimation::usage_percentage_u8(estimated_total, cw);
tracing::info!(
"Forced auto-compact trigger (debug): model={model}, \
{percentage}% full ({estimated_total}/{cw} tokens)",
);
return Some(AutoCompactTriggerInfo {
tokens_used: estimated_total,
context_window: cw,
percentage,
});
}
if let Some(trigger_info) = self.should_auto_compact(estimated_total, context_window) {
tracing::info!(
"Pre-sampling auto-compact trigger: model={model}, \
{}% full ({}/{} tokens)",
trigger_info.percentage,
trigger_info.tokens_used,
trigger_info.context_window,
);
return Some(trigger_info);
}
None
}
/// Returns `Some` when tool call outputs have pushed the estimated token
/// count past the context window, indicating pre-emptive compaction is needed.
pub(crate) async fn check_preflight_overflow(&self) -> Option<AutoCompactTriggerInfo> {
if self
.compaction
.auto_compact_suppressed
.load(std::sync::atomic::Ordering::Relaxed)
!= SUPPRESS_NONE
{
return None;
}
let estimated_total = self.chat_state_handle.get_estimated_total_tokens().await;
let cfg = self.chat_state_handle.get_sampling_config().await?;
let cw = cfg.context_window.get();
if estimated_total <= cw {
return None;
}
let overflow = estimated_total.saturating_sub(cw);
let percentage = kigi_token_estimation::usage_percentage_u8(estimated_total, cw);
tracing::warn!(
estimated_total, context_window = cw, overflow, model = % cfg.model,
"CONTEXT_OVERFLOW_PREFLIGHT: estimated tokens exceed context window \
after tool call outputs"
);
Some(AutoCompactTriggerInfo {
tokens_used: estimated_total,
context_window: cw,
percentage,
})
}
/// On a model change, clear stale suppression the switch can resolve (sticky
/// size/schema — the new window may fit — and a stale per-turn `other`), then
/// compact now if the new window is smaller. Account-state suppression
/// (credit/auth → `SUPPRESS_UNTIL_SUCCESS`) is left intact — a switch can't
/// restore credits or fix auth — and short-circuits the compaction.
pub(crate) async fn maybe_compact_on_model_switch(self: &Arc<Self>) {
let Some(prev) = self.compaction.previous_model.take() else {
return;
};
let Some(cfg) = self.chat_state_handle.get_sampling_config().await else {
return;
};
if cfg.model == prev.model_slug {
return;
}
if self
.compaction
.auto_compact_suppressed
.load(std::sync::atomic::Ordering::Relaxed)
== SUPPRESS_UNTIL_SUCCESS
{
return;
}
self.compaction
.auto_compact_suppressed
.store(SUPPRESS_NONE, std::sync::atomic::Ordering::Relaxed);
if prev.context_window <= cfg.context_window.get() {
return;
}
let total_tokens = self.chat_state_handle.get_estimated_total_tokens().await;
let Some(trigger_info) = self.should_auto_compact(total_tokens, cfg.context_window) else {
return;
};
tracing::info!(
"Proactive model-switch compact: {} ({}) -> {} ({}), {}% full",
prev.model_slug,
prev.context_window,
cfg.model,
cfg.context_window.get(),
trigger_info.percentage,
);
if let Err(e) = self.run_compact_only(trigger_info).await {
tracing::error!(error = % e, "Model-switch compaction failed");
}
}
/// Record the current model for model-switch detection on the next turn.
pub(crate) async fn record_turn_model(&self) {
if let Some(cfg) = self.chat_state_handle.get_sampling_config().await {
self.compaction.previous_model.set(Some(
crate::session::compaction_config::PreviousModelInfo {
model_slug: cfg.model.clone(),
context_window: cfg.context_window.get(),
},
));
}
}
/// Compact without auto-continue. The outer turn loop rebuilds and retries.
/// Emits telemetry (`auto_compact_fired`) and UI notifications automatically.
#[tracing::instrument(
name = "session.compact",
skip_all,
fields(
session_id = %self.session_info.id.0,
trigger = "auto",
mode = tracing::field::Empty,
detail = tracing::field::Empty,
pre_tokens = tracing::field::Empty,
post_tokens = tracing::field::Empty,
success = tracing::field::Empty,
error = tracing::field::Empty,
)
)]
pub(crate) async fn run_compact_only(
self: &Arc<Self>,
trigger_info: AutoCompactTriggerInfo,
) -> Result<(), acp::Error> {
use crate::extensions::notification::SessionUpdate as XaiSessionUpdate;
self.record_compaction_variant();
let tokens_before = self.chat_state_handle.get_total_tokens().await;
tracing::Span::current().record("pre_tokens", tokens_before as i64);
self.signals_handle()
.record_compaction(trigger_info.tokens_used);
self.send_xai_notification(XaiSessionUpdate::AutoCompactStarted {
tokens_used: trigger_info.tokens_used,
context_window: trigger_info.context_window,
percentage: trigger_info.percentage,
reason: format!("Context window {}% full", trigger_info.percentage),
})
.await;
self.maybe_pre_compaction_flush(
trigger_info.tokens_used,
trigger_info.context_window,
"pre_compact_on_error",
)
.await;
let compact_start = std::time::Instant::now();
let result = self
.run_compact_inner(None, None, CompactionTrigger::Auto)
.await;
let elapsed_ms = compact_start.elapsed().as_millis() as i64;
match result {
Ok(()) => {
let tokens_after = self.chat_state_handle.get_total_tokens().await;
let span = tracing::Span::current();
span.record("post_tokens", tokens_after as i64);
span.record("success", true);
self.send_xai_notification(XaiSessionUpdate::AutoCompactCompleted {
tokens_before: Some(trigger_info.tokens_used),
tokens_after,
elapsed_ms: Some(elapsed_ms),
summary_preview: None,
})
.await;
Ok(())
}
Err(e) => {
let span = tracing::Span::current();
span.record("success", false);
span.record("error", e.to_string().as_str());
if self
.compaction
.auto_compact_suppressed
.load(std::sync::atomic::Ordering::Relaxed)
== SUPPRESS_NONE
{
self.send_xai_notification(XaiSessionUpdate::AutoCompactFailed {
error: Self::failure_with_retry_guidance(&e),
})
.await;
}
Err(e)
}
}
}
/// Persist a compaction request artifact for offline prompt iteration.
///
/// Writes `{session_dir}/compaction_requests/{request_id}.json` containing
/// the exact ConversationItem list sent to the compaction model plus the
/// summary (or final error) it produced. The file rides on
/// the post-turn session archive to cloud storage via the existing per-turn upload
/// pipeline — no separate upload path is needed.
///
/// `created_at` is taken from the caller-supplied `started_at` (captured
/// before the retry loop) rather than `Utc::now()` here, so transient
/// retries don't skew the timestamp away from when the call actually
/// started.
///
/// Best-effort: send-failures are logged at `warn` and never surfaced to
/// the user, because the artifact is purely for offline analysis.
#[allow(clippy::too_many_arguments)]
fn persist_compaction_request_artifact(
&self,
chat_history: Vec<ConversationItem>,
tools: Vec<kigi_sampling_types::ToolSpec>,
user_context: Option<&str>,
use_short_prompt: bool,
model: &str,
trigger: CompactionTrigger,
summary: Option<&str>,
error: Option<&acp::Error>,
attempts: u32,
attempt_details: Vec<CompactionAttempt>,
started_at: String,
) {
use crate::extensions::notification::CompactionRequestFile;
let request_id = uuid::Uuid::new_v4().to_string();
let trigger_str = match trigger {
CompactionTrigger::Manual => "manual",
CompactionTrigger::Auto => "auto",
};
let prompt_variant = if use_short_prompt {
"short"
} else {
"detailed"
};
let error_str = error.map(|e| {
e.data
.as_ref()
.and_then(|d| d.as_str())
.unwrap_or("<no error data>")
.to_owned()
});
let artifact = CompactionRequestFile {
schema_version: 2,
request_id,
created_at: started_at,
trigger: trigger_str.to_owned(),
prompt_variant: prompt_variant.to_owned(),
model: model.to_owned(),
user_context: user_context.map(str::to_owned),
chat_history,
tools,
summary: summary.map(str::to_owned),
error: error_str,
attempts,
attempt_details,
};
if self
.notifications
.persistence_tx
.send(PersistenceMsg::CompactionRequest(artifact))
.is_err()
{
tracing::warn!(
session_id = % self.session_info.id.0,
"Failed to send compaction request artifact to persistence channel"
);
}
}
/// Persist a compaction checkpoint: writes the compacted history to a separate file
/// and records a `CompactionCheckpoint` marker in `updates.jsonl`.
///
/// `auto_continue` should be `Some` when this compaction was triggered by auto-compact
/// and an auto-continue prompt will follow.
fn persist_compaction_checkpoint(
&self,
compacted_history: &[ConversationItem],
prompt_index_at_compaction: usize,
auto_continue: Option<crate::extensions::notification::AutoContinueInfo>,
original_user_info: Option<String>,
) {
use crate::extensions::notification::{
CompactionCheckpointFile, CompactionCheckpointInfo, SessionUpdate as XaiSessionUpdate,
};
let checkpoint_id = uuid::Uuid::new_v4().to_string();
let checkpoint_file = format!("compaction_checkpoints/{checkpoint_id}.json");
let created_at = chrono::Utc::now().to_rfc3339();
let file_data = CompactionCheckpointFile {
checkpoint_id: checkpoint_id.clone(),
prompt_index_at_compaction,
compacted_history: compacted_history.to_vec(),
schema_version: 1,
created_at: created_at.clone(),
original_user_info,
reread_file_paths: vec![],
};
if self
.notifications
.persistence_tx
.send(PersistenceMsg::CompactionCheckpoint(file_data))
.is_err()
{
tracing::warn!("Failed to send compaction checkpoint file to persistence channel");
}
let info = CompactionCheckpointInfo {
checkpoint_id,
prompt_index_at_compaction,
checkpoint_file,
auto_continue,
schema_version: 1,
created_at,
};
self.persist_xai_update_only(XaiSessionUpdate::CompactionCheckpoint(Box::new(info)));
tracing::info!(
prompt_index_at_compaction,
"Persisted compaction checkpoint"
);
}
}
#[cfg(test)]
mod inline_auto_compact_flow_tests {
use super::super::support::*;
use super::super::*;
use super::{AutoCompactTriggerInfo, SuppressReason};
use crate::session::acp_session::McpReminderMode;
use crate::terminal::AsyncTerminalRunner;
use crate::terminal::runner::{TerminalError, TerminalRunRequest, TerminalRunResult};
use kigi_paths::AbsPathBuf;
use kigi_workspace::file_system::MockFs;
use kigi_workspace::permission::PermissionHandle;
use std::sync::OnceLock;
use tokio::sync::mpsc;
#[derive(Debug)]
struct DummyTerminal;
#[async_trait::async_trait]
impl AsyncTerminalRunner for DummyTerminal {
async fn run(
&self,
_request: TerminalRunRequest,
) -> Result<TerminalRunResult, TerminalError> {
Err(TerminalError::Other("dummy terminal".into()))
}
}
/// Create a minimal SessionActor for testing auto-compact logic.
async fn create_test_actor(
total_tokens: u64,
context_window: u64,
threshold_percent: u8,
gateway_tx: mpsc::UnboundedSender<kigi_acp_lib::AcpClientMessage>,
persistence_tx: mpsc::UnboundedSender<PersistenceMsg>,
) -> SessionActor {
let cwd = AbsPathBuf::new(std::path::PathBuf::from("/tmp")).unwrap();
let fs = Arc::new(MockFs::new(cwd.to_path_buf()));
let terminal = Arc::new(DummyTerminal {});
let (hunk_tx, _hunk_rx) = tokio::sync::mpsc::unbounded_channel();
let hunk_tracker_handle = kigi_hunk_tracker::HunkTrackerActor::spawn(
"test-auto-compact".to_string(),
cwd.to_path_buf(),
hunk_tx,
kigi_hunk_tracker::TrackingMode::AgentOnly,
tokio_util::sync::CancellationToken::new(),
);
let tool_context =
ToolContext::new(cwd.clone(), None, None, fs, terminal, hunk_tracker_handle);
let state = TokioMutex::new(State {
running_task: None,
pending_inputs: VecDeque::new(),
pending_notifications: Vec::new(),
notifications_suppressed: false,
rewindable: false,
nudges_used_this_session: 0,
});
let (chat_event_tx, _chat_event_rx) = tokio::sync::mpsc::unbounded_channel();
let (event_tx, _event_rx) =
tokio::sync::mpsc::unbounded_channel::<crate::session::replay_events::SessionEvent>();
let chat_state_handle = kigi_chat_state::ChatStateActor::spawn(
vec![],
kigi_sampling_types::SamplingConfig {
base_url: "http://localhost".to_string(),
model: "test".to_string(),
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(context_window)
.expect("test context_window must be non-zero"),
reasoning_effort: None,
stream_tool_calls: None,
},
Box::new(kigi_chat_state::NullChatPersistence),
chat_event_tx,
tokio_util::sync::CancellationToken::new(),
);
chat_state_handle.record_token_usage(total_tokens);
SessionActor {
swarm_mode: std::cell::Cell::new(Default::default()),
session_info: SessionInfo {
id: acp::SessionId::new("test-auto-compact"),
cwd: cwd.as_str().to_string(),
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
notifications: NotificationSender {
gateway: GatewaySender::new(gateway_tx),
gateway_enabled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)),
persistence_tx,
},
permissions: PermissionHandle::allow_all(),
tool_context,
deny_read_globs: Vec::new(),
mcp_state: Arc::new(TokioMutex::new(McpState::new(vec![]))),
mcp_strategy: McpInitStrategy::Blocking,
chat_state_handle,
current_prompt_id: std::sync::Arc::new(std::sync::Mutex::new(None)),
pending_interactions: std::sync::Arc::new(std::sync::Mutex::new(
std::collections::HashMap::new(),
)),
supports_backend_search: std::cell::Cell::new(false),
compactions_remaining: std::cell::Cell::new(None),
compaction_at_tokens: std::cell::Cell::new(None),
doom_loop_recovery: None,
doom_loop_turn_tally: Default::default(),
file_state_tracker: Arc::new(FileStateTracker::new()),
rewind_pending_prompt: std::sync::Mutex::new(None),
startup_hints: StartupHints::default(),
forked_tool_override: None,
compaction: crate::session::compaction_config::CompactionConfig {
threshold_percent: std::cell::Cell::new(threshold_percent),
force_compact: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
context_window_override: None,
count: std::sync::atomic::AtomicU64::new(0),
auto_compact_suppressed: std::sync::atomic::AtomicU8::new(0),
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
memory: crate::session::memory_state::SessionMemory {
flush_config: crate::config::MemoryFlushConfig::default(),
is_flushing: std::sync::atomic::AtomicBool::new(false),
last_flush_compaction: std::sync::atomic::AtomicU64::new(0),
storage: std::cell::RefCell::new(None),
save_on_end: true,
backend_params: None,
initial_injection_config: Default::default(),
context_injected: std::sync::atomic::AtomicBool::new(false),
flush_count: std::sync::atomic::AtomicU64::new(0),
last_flush_content: std::cell::RefCell::new(None),
flush_success_count: std::sync::atomic::AtomicU64::new(0),
flush_error_count: std::sync::atomic::AtomicU64::new(0),
search_counter: std::cell::RefCell::new(None),
injection_count: std::sync::atomic::AtomicU64::new(0),
compaction_recovery_count: std::sync::atomic::AtomicU64::new(0),
chunks_added: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
dream_config: Default::default(),
dream_count: std::sync::atomic::AtomicU64::new(0),
dream_success_count: std::sync::atomic::AtomicU64::new(0),
dream_error_count: std::sync::atomic::AtomicU64::new(0),
},
session_start: std::time::Instant::now(),
inference_idle_timeout: std::time::Duration::from_secs(300),
max_retries: 3,
max_turns: None,
pending_interjections: InterjectionBuffer::new(),
pending_skill_reminders: Mutex::new(Vec::new()),
idle_flush_timeout: None,
dream_check_timeout: None,
last_idle_flush_conversation_len: std::sync::atomic::AtomicUsize::new(0),
event_tx,
buffering_settings: None,
client_identifier: None,
origin_client: None,
feedback_manager: Arc::new(FeedbackManager::local_only("test-session")),
agent: std::cell::RefCell::new(test_agent_default().await),
last_reported_branch: std::sync::Arc::new(parking_lot::Mutex::new(None)),
git_head_enabled: false,
models_manager: Default::default(),
display_cwd: std::sync::OnceLock::new(),
active_agent_type: parking_lot::Mutex::new(None),
queue_exit_reminder_on_approved_exit: Arc::new(std::sync::atomic::AtomicBool::new(
false,
)),
active_skill: parking_lot::Mutex::new(None),
current_prompt_mode: Arc::new(parking_lot::Mutex::new(PromptMode::Agent)),
turn_start_prompt_mode: parking_lot::Mutex::new(PromptMode::Agent),
turn_prompt_mode: Arc::new(parking_lot::Mutex::new(PromptMode::Agent)),
plan_mode: Arc::new(parking_lot::Mutex::new(
crate::session::plan_mode::PlanModeTracker::new(std::path::PathBuf::from(
"/tmp/test-session",
)),
)),
goal_enabled: false,
goal_harness_enabled: std::sync::atomic::AtomicBool::new(false),
goal_harness_availability_reconciled: std::sync::atomic::AtomicBool::new(false),
goal_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::goal_tracker::GoalTracker::new(std::path::PathBuf::from(
"/tmp/test-session",
)),
)),
graph_enabled: false,
graph_tracker: Arc::new(parking_lot::Mutex::new(
crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()),
)),
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_optimizer_enabled: false,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
goal_update_rx: std::cell::RefCell::new(Some(tokio::sync::mpsc::unbounded_channel().1)),
goal_update_tx: tokio::sync::mpsc::unbounded_channel().0,
goal_classifier_enabled: false,
goal_planner_enabled: false,
goal_summary_enabled: false,
goal_verifier_skeptic_count: 1,
goal_role_models: Default::default(),
goal_use_current_model_only: false,
goal_classifier_max_runs:
crate::session::goal_classifier::GOAL_CLASSIFIER_MAX_RUNS_DEFAULT,
goal_strategist_every: 5,
goal_reverify_after: crate::session::acp_session::GOAL_REVERIFY_AFTER_DEFAULT,
goal_plan_reconciled: std::sync::atomic::AtomicBool::new(false),
pending_classifier_completions: parking_lot::Mutex::new(
std::collections::VecDeque::new(),
),
goal_classifier_in_flight: std::sync::atomic::AtomicBool::new(false),
initial_client_mcp_servers: vec![],
tool_metadata_snapshot: Arc::new(std::sync::Mutex::new(Default::default())),
mcp_announced_servers: parking_lot::Mutex::new(std::collections::HashMap::new()),
mcp_reminder_mode: McpReminderMode::Delta,
mcp_reminder_dirty: Arc::new(std::sync::atomic::AtomicBool::new(false)),
mcp_connecting_reminder_injected: std::cell::Cell::new(false),
mcp_handshakes_done: Arc::new(tokio::sync::Notify::new()),
user_input_generation: std::sync::atomic::AtomicU64::new(0),
laziness_debug_log: None,
deferred_prefix: TaskSlot::new(),
extension_registry: kigi_agent_lifecycle::LocalExtensionRegistry::default(),
last_announced_local_date: std::cell::Cell::new(chrono::Local::now().date_naive()),
last_search_prompt_index: std::sync::atomic::AtomicI64::new(-1),
last_api_request_at: std::sync::atomic::AtomicI64::new(0),
hook_registry: std::cell::RefCell::new(None),
client_hooks: Default::default(),
hook_resolved_workspace_root: String::new(),
vcs_kind: kigi_workspace::session::git::VcsKind::Git,
hook_load_errors: std::cell::RefCell::new(Vec::new()),
plugin_registry: std::cell::RefCell::new(None),
plugin_registry_handle: None,
events: crate::session::events::EventTracker::new(std::path::Path::new("/tmp")),
current_turn_number: std::cell::Cell::new(0),
last_recap_main_turn: std::cell::Cell::new(0),
recap_in_flight: std::cell::Cell::new(false),
recap_epoch: std::cell::Cell::new(0),
session_turn_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
streaming_turn_capture: parking_lot::Mutex::new(
crate::session::acp_session::StreamingTurnCapture::default(),
),
turn_stream_drained: parking_lot::Mutex::new(None),
sampler_handle: kigi_sampler::SamplerHandle::noop(),
rebuild_spec: crate::session::agent_rebuild::test_rebuild_spec_default(),
image_description_model: crate::test_support::TEST_MODEL.to_owned(),
image_describe_cache: Arc::new(
crate::session::image_describe::ImageDescribeCache::new(),
),
subagent_spawn_info: parking_lot::Mutex::new(std::collections::HashMap::new()),
subagent_token_records: parking_lot::Mutex::new(std::collections::HashMap::new()),
workspace_ops: kigi_workspace::WorkspaceOps::for_test(),
}
}
/// Test check_auto_compact_needed uses state values.
#[tokio::test(flavor = "current_thread")]
async fn test_check_auto_compact_needed_uses_state() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _gateway_rx) =
mpsc::unbounded_channel::<kigi_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) = mpsc::unbounded_channel::<PersistenceMsg>();
let actor =
create_test_actor(90_000, 100_000, 85, gateway_tx, persistence_tx).await;
let result = actor.check_auto_compact_needed().await;
assert!(result.is_some(), "Should trigger at 90%");
let info = result.unwrap();
assert_eq!(info.percentage, 90);
})
.await;
}
/// Test that overriding context_window on the sampling config changes
/// auto-compact behavior. Forked sessions must use the new model's
/// context window, not the source session's. Without this, auto-compact
/// fires at the wrong threshold.
#[tokio::test(flavor = "current_thread")]
async fn test_context_window_override_affects_auto_compact() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _gateway_rx) =
mpsc::unbounded_channel::<kigi_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) = mpsc::unbounded_channel::<PersistenceMsg>();
let actor =
create_test_actor(86_000, 100_000, 85, gateway_tx, persistence_tx).await;
let result = actor.check_auto_compact_needed().await;
assert!(result.is_some(), "Should trigger at 86% of 100K window");
if let Some(mut cfg) = actor.chat_state_handle.get_sampling_config().await {
cfg.model = "larger-model".to_string();
cfg.context_window = std::num::NonZeroU64::new(200_000).unwrap();
actor.chat_state_handle.update_sampling_config(cfg);
}
let result = actor.check_auto_compact_needed().await;
assert!(
result.is_none(),
"Should NOT trigger at 43% of 200K window after context_window override"
);
})
.await;
}
/// Test the reverse direction: overriding to a smaller context window
/// should make auto-compact trigger sooner.
#[tokio::test(flavor = "current_thread")]
async fn test_context_window_override_to_smaller_triggers_compact() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _gateway_rx) =
mpsc::unbounded_channel::<kigi_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) = mpsc::unbounded_channel::<PersistenceMsg>();
let actor =
create_test_actor(86_000, 200_000, 85, gateway_tx, persistence_tx).await;
let result = actor.check_auto_compact_needed().await;
assert!(result.is_none(), "Should NOT trigger at 43% of 200K window");
if let Some(mut cfg) = actor.chat_state_handle.get_sampling_config().await {
cfg.model = "smaller-model".to_string();
cfg.context_window = std::num::NonZeroU64::new(100_000).unwrap();
actor.chat_state_handle.update_sampling_config(cfg);
}
let result = actor.check_auto_compact_needed().await;
assert!(
result.is_some(),
"Should trigger at 86% of 100K window after context_window override"
);
})
.await;
}
/// Suppression gates both AUTO paths; the reset scope depends on the reason:
/// `other` clears next turn, `credit_block` holds until a successful model call,
/// `size` is sticky until a full reset (success / rewind / model switch).
#[tokio::test(flavor = "current_thread")]
async fn suppression_gates_and_reset_is_reason_scoped() {
use crate::session::compaction_config::{
SUPPRESS_NONE, SUPPRESS_TURN, SUPPRESS_UNTIL_SUCCESS,
};
use std::sync::atomic::Ordering::Relaxed;
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _gateway_rx) = mpsc::unbounded_channel();
let (persistence_tx, _persistence_rx) = mpsc::unbounded_channel();
let actor =
create_test_actor(214_000, 200_000, 85, gateway_tx, persistence_tx).await;
let err = api_error_with_context_window(200_000);
assert!(actor.check_auto_compact_needed().await.is_some());
assert!(actor.should_compact_on_error(&err).await);
actor
.suppress_auto_compaction(SuppressReason::Other, 1_000, 200_000)
.await;
assert!(actor.check_auto_compact_needed().await.is_none());
assert!(!actor.should_compact_on_error(&err).await);
let _ = actor.compaction.auto_compact_suppressed.compare_exchange(
SUPPRESS_TURN,
SUPPRESS_NONE,
Relaxed,
Relaxed,
);
assert!(actor.check_auto_compact_needed().await.is_some());
actor
.suppress_auto_compaction(SuppressReason::CreditBlock, 1_000, 200_000)
.await;
assert_eq!(
actor.compaction.auto_compact_suppressed.load(Relaxed),
SUPPRESS_UNTIL_SUCCESS
);
assert!(actor.check_auto_compact_needed().await.is_none());
assert!(!actor.should_compact_on_error(&err).await);
let _ = actor.compaction.auto_compact_suppressed.compare_exchange(
SUPPRESS_TURN,
SUPPRESS_NONE,
Relaxed,
Relaxed,
);
assert!(
actor.check_auto_compact_needed().await.is_none(),
"credit-block suppression must survive the per-turn reset"
);
let _ = actor.compaction.auto_compact_suppressed.compare_exchange(
SUPPRESS_UNTIL_SUCCESS,
SUPPRESS_NONE,
Relaxed,
Relaxed,
);
assert!(actor.check_auto_compact_needed().await.is_some());
actor
.suppress_auto_compaction(SuppressReason::Size, 1_000, 200_000)
.await;
assert!(actor.check_auto_compact_needed().await.is_none());
let _ = actor.compaction.auto_compact_suppressed.compare_exchange(
SUPPRESS_TURN,
SUPPRESS_NONE,
Relaxed,
Relaxed,
);
assert!(
actor.check_auto_compact_needed().await.is_none(),
"sticky suppression must survive the per-turn reset"
);
actor
.compaction
.auto_compact_suppressed
.store(SUPPRESS_NONE, Relaxed);
assert!(actor.check_auto_compact_needed().await.is_some());
})
.await;
}
/// A model switch clears suppression the switch (or the fresh budget-driven
/// trigger) can resolve — sticky size/schema and a stale per-turn `other` — so
/// the gates re-evaluate against the new window. Account-state credit/auth is
/// covered by `model_switch_keeps_account_state_suppression`.
#[tokio::test(flavor = "current_thread")]
async fn model_switch_clears_sticky_suppression() {
use crate::session::compaction_config::{PreviousModelInfo, SUPPRESS_NONE};
use std::sync::atomic::Ordering::Relaxed;
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _gateway_rx) = mpsc::unbounded_channel();
let (persistence_tx, _persistence_rx) = mpsc::unbounded_channel();
let actor = Arc::new(
create_test_actor(50_000, 200_000, 85, gateway_tx, persistence_tx).await,
);
for reason in [SuppressReason::Size, SuppressReason::Other] {
actor.suppress_auto_compaction(reason, 1_000, 200_000).await;
assert_ne!(
actor.compaction.auto_compact_suppressed.load(Relaxed),
SUPPRESS_NONE,
"{reason:?} should set suppression"
);
actor.compaction.previous_model.set(Some(PreviousModelInfo {
model_slug: "old-small-model".to_string(),
context_window: 100_000,
}));
actor.maybe_compact_on_model_switch().await;
assert_eq!(
actor.compaction.auto_compact_suppressed.load(Relaxed),
SUPPRESS_NONE,
"model switch must clear {reason:?} suppression so the gates re-evaluate"
);
}
})
.await;
}
/// A model switch resets the context budget, so it clears sticky (size/schema)
/// suppression — but NOT account-state suppression (credit/auth →
/// SUPPRESS_UNTIL_SUCCESS), which a switch can't resolve. It must also not
/// proactively compact while that suppression is active (the switch-to-smaller
/// window path would otherwise fire a doomed compaction).
#[tokio::test(flavor = "current_thread")]
async fn model_switch_keeps_account_state_suppression() {
use crate::session::compaction_config::{PreviousModelInfo, SUPPRESS_UNTIL_SUCCESS};
use std::sync::atomic::Ordering::Relaxed;
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _gateway_rx) = mpsc::unbounded_channel();
let (persistence_tx, _persistence_rx) = mpsc::unbounded_channel();
let actor = Arc::new(
create_test_actor(214_000, 200_000, 85, gateway_tx, persistence_tx).await,
);
actor
.suppress_auto_compaction(SuppressReason::CreditBlock, 1_000, 200_000)
.await;
assert_eq!(
actor.compaction.auto_compact_suppressed.load(Relaxed),
SUPPRESS_UNTIL_SUCCESS
);
actor.compaction.previous_model.set(Some(PreviousModelInfo {
model_slug: "old-big-model".to_string(),
context_window: 400_000,
}));
actor.maybe_compact_on_model_switch().await;
assert_eq!(
actor.compaction.auto_compact_suppressed.load(Relaxed),
SUPPRESS_UNTIL_SUCCESS,
"model switch must NOT clear credit/auth suppression"
);
})
.await;
}
/// The per-turn suppression notification is tailored to the failure reason.
#[tokio::test(flavor = "current_thread")]
async fn suppression_notification_is_reason_specific() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
async fn notification_for(reason: SuppressReason) -> String {
let (gateway_tx, _gateway_rx) = mpsc::unbounded_channel();
let (persistence_tx, mut persistence_rx) = mpsc::unbounded_channel();
let actor = create_test_actor(
10_000,
200_000,
85,
gateway_tx,
persistence_tx,
)
.await;
actor.suppress_auto_compaction(reason, 1_000, 200_000).await;
let mut text = None;
while let Ok(msg) = persistence_rx.try_recv() {
if let PersistenceMsg::Update(
crate::session::storage::SessionUpdate::Xai(notif),
) = msg
&& let crate::extensions::notification::SessionUpdate::AutoCompactFailed {
error,
} = &notif.update
{
text = Some(error.clone());
}
}
text.expect("expected an AutoCompactFailed notification")
}
let credit = notification_for(SuppressReason::CreditBlock).await;
assert!(credit.contains("spending limit"), "credit_block: {credit}");
let auth = notification_for(SuppressReason::Auth).await;
assert!(auth.contains("/login"), "auth: {auth}");
let size = notification_for(SuppressReason::Size).await;
assert!(size.contains("too large to compact"), "size: {size}");
let schema = notification_for(SuppressReason::Schema).await;
assert!(schema.contains("can't be summarized"), "schema: {schema}");
let other = notification_for(SuppressReason::Other).await;
assert!(other.contains("/new"), "other: {other}");
})
.await;
}
/// Mock LLM endpoint answering every request with a deterministic 400.
async fn spawn_deterministic_400_server() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf).await;
let body =
r#"{"error":{"type":"invalid_request_error","message":"bad schema"}}"#;
let resp = format!(
"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes()).await;
});
}
});
format!("http://{addr}")
}
/// A deterministic failure suppresses auto-compaction only on the AUTO
/// path — never for a bare manual `/compact`.
#[tokio::test(flavor = "current_thread")]
async fn bare_manual_compact_failure_does_not_suppress_auto() {
use crate::session::compaction_config::SUPPRESS_NONE;
use std::sync::atomic::Ordering::Relaxed;
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _gateway_rx) = mpsc::unbounded_channel();
let (persistence_tx, _persistence_rx) = mpsc::unbounded_channel();
let actor = Arc::new(
create_test_actor(50_000, 200_000, 85, gateway_tx, persistence_tx).await,
);
let base_url = spawn_deterministic_400_server().await;
let mut cfg = actor.chat_state_handle.get_sampling_config().await.unwrap();
cfg.base_url = base_url;
actor.chat_state_handle.update_sampling_config(cfg);
actor.chat_state_handle.replace_conversation(vec![
ConversationItem::system("sys"),
ConversationItem::user("hello"),
]);
let result = actor.run_compact(None).await;
assert!(result.is_err(), "mock 400 must fail the compaction");
assert_eq!(
actor.compaction.auto_compact_suppressed.load(Relaxed),
SUPPRESS_NONE,
"manual /compact (even without args) must never set auto-compact suppression"
);
let result = actor
.run_compact_only(AutoCompactTriggerInfo {
tokens_used: 180_000,
context_window: 200_000,
percentage: 90,
})
.await;
assert!(result.is_err(), "mock 400 must fail the compaction");
assert_ne!(
actor.compaction.auto_compact_suppressed.load(Relaxed),
SUPPRESS_NONE,
"the same deterministic failure on the AUTO path must suppress"
);
})
.await;
}
/// A forked session whose whole-transcript inherited prefix alone exceeds
/// the auto-compact threshold releases the prefix on compaction (so the
/// conversation can actually shrink below the threshold) and keeps the
/// release sticky across further compactions (no unbounded compaction loop).
#[tokio::test(flavor = "current_thread")]
async fn forked_prefix_released_under_pressure_and_stays_released() {
use crate::session::compaction_config::SUPPRESS_NONE;
use kigi_test_support::MockInferenceServer;
use std::sync::atomic::Ordering::Relaxed;
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _gateway_rx) = mpsc::unbounded_channel();
let (persistence_tx, _persistence_rx) = mpsc::unbounded_channel();
let filler = "x".repeat(8_000);
let mut conv = vec![ConversationItem::system("small system prompt")];
for i in 0..9 {
conv.push(ConversationItem::user(format!("u{i} {filler}")));
conv.push(ConversationItem::assistant(format!("a{i} {filler}")));
}
conv.push(ConversationItem::user("final query"));
let prefix_len = conv.len();
let mut actor = create_test_actor(0, 40_000, 80, gateway_tx, persistence_tx).await;
actor.startup_hints.inherited_prefix_len = Some(prefix_len);
let actor = Arc::new(actor);
let server = MockInferenceServer::start().await.unwrap();
server.set_response("Summary of prior work. ".repeat(30));
let mut cfg = actor.chat_state_handle.get_sampling_config().await.unwrap();
cfg.base_url = server.url();
actor.chat_state_handle.update_sampling_config(cfg);
actor.chat_state_handle.replace_conversation(conv);
let threshold_tokens = 40_000u64 * 80 / 100;
let before = actor.chat_state_handle.get_total_tokens().await;
assert!(
before > threshold_tokens,
"seed must exceed threshold: {before} <= {threshold_tokens}"
);
let result = actor.run_compact(None).await;
assert!(result.is_ok(), "compaction should succeed: {result:?}");
assert!(
actor.compaction.prefix_released.load(Relaxed),
"prefix must be released under pressure"
);
let after = actor.chat_state_handle.get_total_tokens().await;
assert!(
after < threshold_tokens,
"released history must drop below threshold: {after} >= {threshold_tokens}"
);
assert!(
actor.chat_state_handle.get_conversation_len().await < prefix_len,
"conversation must shrink below the pinned prefix floor"
);
assert_eq!(
actor.compaction.auto_compact_suppressed.load(Relaxed),
SUPPRESS_NONE,
"a shrunk conversation must not suppress AUTO"
);
let result = actor.run_compact(None).await;
assert!(
result.is_ok(),
"second compaction should succeed: {result:?}"
);
assert!(
actor.compaction.prefix_released.load(Relaxed),
"release must stay sticky across compactions"
);
let after2 = actor.chat_state_handle.get_total_tokens().await;
assert!(
after2 < threshold_tokens,
"sticky release must keep the session under threshold: {after2}"
);
})
.await;
}
/// When even the released (summarized) history still exceeds the threshold
/// -- the pathological case where the system prompt alone is over budget --
/// a forked session sets sticky suppression (WITHOUT a user-facing failure
/// event) instead of clearing it, so AUTO is not immediately re-armed while the
/// compaction itself still reports success.
#[tokio::test(flavor = "current_thread")]
async fn forked_release_still_over_threshold_suppresses_auto() {
use crate::session::compaction_config::SUPPRESS_STICKY;
use kigi_test_support::MockInferenceServer;
use std::sync::atomic::Ordering::Relaxed;
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _gateway_rx) = mpsc::unbounded_channel();
let (persistence_tx, mut persistence_rx) = mpsc::unbounded_channel();
let huge_system = "s".repeat(150_000);
let conv = vec![
ConversationItem::system(huge_system),
ConversationItem::user("q"),
ConversationItem::assistant("a"),
ConversationItem::user("final query"),
];
let prefix_len = conv.len();
let mut actor = create_test_actor(0, 40_000, 80, gateway_tx, persistence_tx).await;
actor.startup_hints.inherited_prefix_len = Some(prefix_len);
let actor = Arc::new(actor);
let server = MockInferenceServer::start().await.unwrap();
server.set_response("Summary. ".repeat(70));
let mut cfg = actor.chat_state_handle.get_sampling_config().await.unwrap();
cfg.base_url = server.url();
actor.chat_state_handle.update_sampling_config(cfg);
actor.chat_state_handle.replace_conversation(conv);
let threshold_tokens = 40_000u64 * 80 / 100;
let before = actor.chat_state_handle.get_total_tokens().await;
assert!(
before > threshold_tokens,
"seed must exceed threshold: {before}"
);
let result = actor.run_compact(None).await;
assert!(result.is_ok(), "compaction should succeed: {result:?}");
assert!(
actor.compaction.prefix_released.load(Relaxed),
"prefix must be released under pressure"
);
assert_eq!(
actor.compaction.auto_compact_suppressed.load(Relaxed),
SUPPRESS_STICKY,
"an over-threshold released history must set sticky suppression"
);
let mut saw_failure = false;
while let Ok(msg) = persistence_rx.try_recv() {
if let PersistenceMsg::Update(crate::session::storage::SessionUpdate::Xai(
notif,
)) = msg
&& matches!(
&notif.update,
crate::extensions::notification::SessionUpdate::AutoCompactFailed { .. }
)
{
saw_failure = true;
}
}
assert!(
!saw_failure,
"successful compaction must not emit AutoCompactFailed"
);
})
.await;
}
/// `classify_suppress_reason` maps each deterministic-failure shape to its
/// fixed [`SuppressReason`].
#[test]
fn classify_suppress_reason_maps_error_text() {
let classify = SessionActor::classify_suppress_reason;
assert_eq!(
classify("caller does not have permission … spending-limit reached"),
SuppressReason::CreditBlock
);
assert_eq!(
classify("you have run out of credits"),
SuppressReason::CreditBlock
);
assert_eq!(
classify("API error (status 402 Payment Required): Kigi usage balance exhausted"),
SuppressReason::CreditBlock
);
assert_eq!(
classify("Kigi usage limit reached"),
SuppressReason::CreditBlock
);
assert_eq!(
classify("This model's maximum prompt length is 500000"),
SuppressReason::Size
);
assert_eq!(
classify("compact failed: The prompt is too long for this model's context window."),
SuppressReason::Size
);
assert_eq!(
classify("provider error: context_length_exceeded"),
SuppressReason::Size
);
assert_eq!(
classify("API error (status 401 Unauthorized)"),
SuppressReason::Auth
);
assert_eq!(
classify("provider returned invalid_request_error: messages.3"),
SuppressReason::Schema
);
assert_eq!(
classify("upstream 500 internal error"),
SuppressReason::Other
);
}
/// `SuppressReason::as_str` is the stable telemetry wire value — BQ/OTLP and
/// dashboards key off these exact strings. Lock them so a rename can't break monitoring.
#[test]
fn suppress_reason_as_str_is_stable() {
assert_eq!(SuppressReason::CreditBlock.as_str(), "credit_block");
assert_eq!(SuppressReason::Size.as_str(), "size");
assert_eq!(SuppressReason::Auth.as_str(), "auth");
assert_eq!(SuppressReason::Schema.as_str(), "schema");
assert_eq!(SuppressReason::Other.as_str(), "other");
}
mod preserve_prefix {
use super::super::preserve_inherited_prefix;
use super::super::project_preserved_reseed_tokens;
use kigi_sampling_types::conversation::ConversationItem;
#[test]
fn splices_inherited_with_compacted_suffix() {
let conversation = vec![
ConversationItem::system("sys"),
ConversationItem::user("parent q1"),
ConversationItem::assistant("parent a1"),
ConversationItem::user("child q1"),
];
let compacted = vec![
ConversationItem::system("sys"),
ConversationItem::user("summary"),
];
let items = preserve_inherited_prefix(&conversation, compacted, 3).expect("Ok");
assert_eq!(items.len(), 4);
assert!(matches!(items[0], ConversationItem::System(_)));
}
/// Invariant: a head-only prefix lets compaction shrink the conversation;
/// a whole-transcript prefix does not (that pinned floor is the loop).
#[test]
fn head_only_shrinks_full_transcript_does_not() {
let mut conversation = vec![ConversationItem::system("sys")];
for i in 0..8 {
conversation.push(ConversationItem::user(format!("u{i}")));
conversation.push(ConversationItem::assistant(format!("a{i}")));
}
let compacted = vec![
ConversationItem::system("sys"),
ConversationItem::assistant("summary"),
];
let fixed = preserve_inherited_prefix(&conversation, compacted.clone(), 1).expect("Ok");
assert!(fixed.len() < conversation.len(), "head-only shrinks");
let buggy = preserve_inherited_prefix(&conversation, compacted, conversation.len())
.expect("Ok");
assert!(
buggy.len() >= conversation.len(),
"full prefix never shrinks"
);
}
/// The reseed projection calibrates the bytes/4 estimate to real tokens
/// (ratio != 1) and caps at the pre-compaction total, so the release
/// decision reflects what the trigger applies next turn.
#[test]
fn project_preserved_reseed_tokens_calibrates_and_caps() {
assert_eq!(
project_preserved_reseed_tokens(30_000, 100_000, 50_000),
60_000
);
assert_eq!(
project_preserved_reseed_tokens(40_000, 70_000, 35_000),
70_000
);
assert_eq!(
project_preserved_reseed_tokens(20_000, 40_000, 40_000),
20_000
);
assert_eq!(project_preserved_reseed_tokens(10, 5, 0), 5);
}
/// Both prefix and re-injected suffix may carry AGENTS.md; the splice must
/// leave exactly one (else the model sees project instructions twice).
#[test]
fn does_not_duplicate_agents_md() {
let conversation = vec![
ConversationItem::system("sys"),
ConversationItem::project_instructions("AGENTS.md"),
ConversationItem::user("work"),
];
let compacted = vec![
ConversationItem::system("sys"),
ConversationItem::project_instructions("AGENTS.md"),
ConversationItem::user("summary"),
];
let items = preserve_inherited_prefix(&conversation, compacted, 2).expect("Ok");
let pi = items
.iter()
.filter(|i| super::super::is_project_instructions(i))
.count();
assert_eq!(pi, 1, "exactly one project-instructions item, not two");
}
#[test]
fn keeps_reinjected_agents_md_when_prefix_lacks_it() {
let conversation = vec![
ConversationItem::system("sys"),
ConversationItem::user("work"),
];
let compacted = vec![
ConversationItem::system("sys"),
ConversationItem::project_instructions("AGENTS.md"),
ConversationItem::user("summary"),
];
let items = preserve_inherited_prefix(&conversation, compacted, 1).expect("Ok");
let pi = items
.iter()
.filter(|i| super::super::is_project_instructions(i))
.count();
assert_eq!(
pi, 1,
"re-injected AGENTS.md preserved when prefix lacks one"
);
}
}
#[allow(clippy::field_reassign_with_default)]
async fn create_test_actor_with_memory(
total_tokens: u64,
context_window: u64,
threshold_percent: u8,
gateway_tx: mpsc::UnboundedSender<kigi_acp_lib::AcpClientMessage>,
persistence_tx: mpsc::UnboundedSender<PersistenceMsg>,
memory_config: Option<crate::config::MemoryConfig>,
) -> SessionActor {
let tmp = tempfile::TempDir::new().unwrap();
let cwd_path = tmp.path().to_path_buf();
let memory_storage = memory_config
.as_ref()
.filter(|mc| mc.enabled)
.map(|_| crate::session::memory::MemoryStorage::new(&cwd_path, None));
std::mem::forget(tmp);
let memory_initial_injection_config = memory_config
.as_ref()
.map_or_else(Default::default, |mc| mc.initial_injection.clone());
let mut actor = create_test_actor(
total_tokens,
context_window,
threshold_percent,
gateway_tx,
persistence_tx,
)
.await;
actor.memory = crate::session::memory_state::SessionMemory {
flush_config: memory_config
.as_ref()
.map_or_else(Default::default, |mc| mc.flush.clone()),
is_flushing: std::sync::atomic::AtomicBool::new(false),
last_flush_compaction: std::sync::atomic::AtomicU64::new(0),
storage: std::cell::RefCell::new(memory_storage),
save_on_end: true,
backend_params: None,
initial_injection_config: memory_initial_injection_config,
context_injected: std::sync::atomic::AtomicBool::new(false),
flush_count: std::sync::atomic::AtomicU64::new(0),
last_flush_content: std::cell::RefCell::new(None),
flush_success_count: std::sync::atomic::AtomicU64::new(0),
flush_error_count: std::sync::atomic::AtomicU64::new(0),
search_counter: std::cell::RefCell::new(None),
injection_count: std::sync::atomic::AtomicU64::new(0),
compaction_recovery_count: std::sync::atomic::AtomicU64::new(0),
chunks_added: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
dream_config: Default::default(),
dream_count: std::sync::atomic::AtomicU64::new(0),
dream_success_count: std::sync::atomic::AtomicU64::new(0),
dream_error_count: std::sync::atomic::AtomicU64::new(0),
};
actor.idle_flush_timeout = memory_config
.as_ref()
.and_then(|mc| mc.flush.idle_timeout_secs)
.map(std::time::Duration::from_secs);
actor.dream_check_timeout = memory_config
.as_ref()
.filter(|mc| mc.dream.enabled)
.and_then(|mc| mc.dream.check_interval_secs)
.filter(|&s| s > 0)
.map(std::time::Duration::from_secs);
actor
}
/// Verify that `last_idle_flush_conversation_len` is reset after
/// compaction shrinks the conversation. Without this reset the
/// interval flush guard (`current_len > last_len`) stays false
/// because the compacted conversation is shorter than the stored
/// pre-compaction length.
#[tokio::test(flavor = "current_thread")]
#[allow(clippy::field_reassign_with_default)]
async fn test_idle_flush_conversation_len_reset_after_compaction() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _) = mpsc::unbounded_channel();
let (persistence_tx, _) = mpsc::unbounded_channel();
let mut config = crate::config::MemoryConfig::default();
config.enabled = true;
config.flush.idle_timeout_secs = Some(60);
let actor = create_test_actor_with_memory(
50_000,
100_000,
85,
gateway_tx,
persistence_tx,
Some(config),
)
.await;
for _ in 0..80 {
actor
.chat_state_handle
.push_user_message(ConversationItem::user("hello".to_string()));
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
actor
.last_idle_flush_conversation_len
.store(80, std::sync::atomic::Ordering::Relaxed);
{
let current_len = actor.chat_state_handle.get_conversation_len().await;
let last_len = actor
.last_idle_flush_conversation_len
.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(current_len, 80);
assert!(
current_len <= last_len,
"guard should block: no new messages"
);
}
{
let compacted = vec![ConversationItem::user("compacted summary".to_string())];
let new_len = compacted.len();
actor
.chat_state_handle
.replace_conversation_for_compaction(compacted);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
actor
.last_idle_flush_conversation_len
.store(new_len, std::sync::atomic::Ordering::Relaxed);
}
{
actor
.chat_state_handle
.push_user_message(ConversationItem::user("new message".to_string()));
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let current_len = actor.chat_state_handle.get_conversation_len().await;
let last_len = actor
.last_idle_flush_conversation_len
.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(current_len, 2, "summary + new message");
assert_eq!(last_len, 1, "reset to post-compaction length");
assert!(
current_len > last_len,
"guard should allow flush after compaction + new message"
);
}
})
.await;
}
fn api_error_with_context_window(context_window: u64) -> kigi_sampler::SamplingErrorInfo {
kigi_sampler::SamplingErrorInfo {
kind: kigi_sampler::SamplingErrorKind::Api,
status_code: Some(400),
message: "prompt is too long".to_string(),
is_retryable: false,
retry_after_secs: None,
model_metadata: Some(crate::sampling::ResponseModelMetadata {
context_window: Some(context_window),
max_completion_tokens: None,
models_etag: None,
}),
empty_response_context: None,
doom_loop_triggers: None,
doom_loop_aborted_at_chunk: None,
}
}
/// Primary scenario: remote settings shrinks the context window mid-session.
/// The shell's last-known token count (214K) exceeds the new limit (200K) —
/// should_compact_on_error must return true so the session can recover.
#[tokio::test(flavor = "current_thread")]
async fn test_compact_on_error_triggers_when_tokens_exceed_new_window() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _) = mpsc::unbounded_channel::<kigi_acp_lib::AcpClientMessage>();
let (persistence_tx, _) = mpsc::unbounded_channel::<PersistenceMsg>();
let actor =
create_test_actor(214_000, 1_000_000, 85, gateway_tx, persistence_tx).await;
let err = api_error_with_context_window(200_000);
assert!(actor.should_compact_on_error(&err).await);
})
.await;
}
/// When tracked tokens are within the new limit, the error was not a context
/// overflow — do not compact.
#[tokio::test(flavor = "current_thread")]
async fn test_compact_on_error_no_trigger_when_tokens_within_new_window() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _) = mpsc::unbounded_channel::<kigi_acp_lib::AcpClientMessage>();
let (persistence_tx, _) = mpsc::unbounded_channel::<PersistenceMsg>();
let actor =
create_test_actor(150_000, 1_000_000, 85, gateway_tx, persistence_tx).await;
let err = api_error_with_context_window(200_000);
assert!(!actor.should_compact_on_error(&err).await);
})
.await;
}
/// If the proxy hasn't been updated yet, model_metadata is None — must be
/// a no-op for backwards compatibility.
#[tokio::test(flavor = "current_thread")]
async fn test_compact_on_error_noop_without_model_metadata() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _) = mpsc::unbounded_channel::<kigi_acp_lib::AcpClientMessage>();
let (persistence_tx, _) = mpsc::unbounded_channel::<PersistenceMsg>();
let actor =
create_test_actor(500_000, 200_000, 85, gateway_tx, persistence_tx).await;
let err = kigi_sampler::SamplingErrorInfo {
kind: kigi_sampler::SamplingErrorKind::Api,
status_code: Some(400),
message: "prompt is too long".to_string(),
is_retryable: false,
retry_after_secs: None,
model_metadata: None,
empty_response_context: None,
doom_loop_triggers: None,
doom_loop_aborted_at_chunk: None,
};
assert!(!actor.should_compact_on_error(&err).await);
})
.await;
}
/// Pre-sampling check uses estimated tokens (includes tool-result delta).
#[tokio::test(flavor = "current_thread")]
async fn test_pre_sampling_uses_estimated_tokens() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _) = mpsc::unbounded_channel::<kigi_acp_lib::AcpClientMessage>();
let (persistence_tx, _) = mpsc::unbounded_channel::<PersistenceMsg>();
let actor =
create_test_actor(80_000, 100_000, 85, gateway_tx, persistence_tx).await;
let result = actor.check_auto_compact_needed().await;
assert!(result.is_none(), "80% should not trigger at 85% threshold");
actor.chat_state_handle.record_token_usage(90_000);
let result = actor.check_auto_compact_needed().await;
assert!(result.is_some(), "90% should trigger");
assert_eq!(result.unwrap().percentage, 90);
})
.await;
}
/// Model-switch compaction fires when switching to a smaller context window.
#[tokio::test(flavor = "current_thread")]
async fn test_model_switch_compaction_triggers_on_downgrade() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _) = mpsc::unbounded_channel::<kigi_acp_lib::AcpClientMessage>();
let (persistence_tx, _) = mpsc::unbounded_channel::<PersistenceMsg>();
let actor =
create_test_actor(86_000, 100_000, 85, gateway_tx, persistence_tx).await;
actor.compaction.previous_model.set(Some(
crate::session::compaction_config::PreviousModelInfo {
model_slug: "large-model".to_string(),
context_window: 200_000,
},
));
let prev = actor.compaction.previous_model.take();
assert!(prev.is_some());
let prev = prev.unwrap();
assert_eq!(prev.context_window, 200_000);
let cfg = actor.chat_state_handle.get_sampling_config().await.unwrap();
assert!(prev.context_window > cfg.context_window.get());
let total = actor.chat_state_handle.get_estimated_total_tokens().await;
let trigger = actor.should_auto_compact(total, cfg.context_window);
assert!(trigger.is_some(), "86% > 85% threshold, should trigger");
actor.compaction.previous_model.set(Some(
crate::session::compaction_config::PreviousModelInfo {
model_slug: "small-model".to_string(),
context_window: 50_000,
},
));
let prev = actor.compaction.previous_model.take().unwrap();
assert!(prev.context_window <= cfg.context_window.get());
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn get_transcript_path_returns_some_when_file_exists() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (gateway_tx, _gateway_rx) =
mpsc::unbounded_channel::<kigi_acp_lib::AcpClientMessage>();
let (persistence_tx, _persistence_rx) = mpsc::unbounded_channel::<PersistenceMsg>();
let mut actor =
create_test_actor(50_000, 200_000, 85, gateway_tx, persistence_tx).await;
actor.compaction.compaction_mode = kigi_chat_state::CompactionMode::Transcript;
let session_dir = crate::session::persistence::session_dir(&actor.session_info);
std::fs::create_dir_all(&session_dir).unwrap();
let updates_path = session_dir.join("updates.jsonl");
std::fs::write(&updates_path, "{}\n").unwrap();
let result = actor.get_transcript_path();
assert!(result.is_some(), "file exists → Some");
assert!(
result.as_ref().unwrap().ends_with("updates.jsonl"),
"path should end with updates.jsonl, got: {:?}",
result,
);
let hint = actor.transcript_hint().expect("transcript hint present");
assert!(hint.contains("read the full transcript"));
assert!(hint.ends_with("updates.jsonl"));
actor.compaction.compaction_mode = kigi_chat_state::CompactionMode::Summary;
assert!(actor.transcript_hint().is_none());
let _ = std::fs::remove_file(&updates_path);
let _ = std::fs::remove_dir_all(&session_dir);
})
.await;
}
}
#[cfg(test)]
mod failure_message_tests {
use super::SessionActor;
use agent_client_protocol as acp;
#[test]
fn transient_failure_message_carries_guidance_and_the_decoded_detail() {
let err = acp::Error::internal_error().data(
"Compact failed: compact failed: HTTP 500 from upstream\n\nRequest URL: http://u:TEST_PASSWORD@host/v1",
);
let text = SessionActor::failure_with_retry_guidance(&err);
let (headline, detail) = text.split_once('\n').expect("guidance + detail");
assert_eq!(
headline,
"it'll retry on the next turn, or start a new session using /new."
);
assert_eq!(detail, "HTTP 500 from upstream");
assert!(!text.contains("TEST_PASSWORD"));
assert_eq!(
SessionActor::failure_with_retry_guidance(&acp::Error::internal_error().data(" ")),
"it'll retry on the next turn, or start a new session using /new."
);
}
}