From 32c141c9e291447e7aeb3ca77d02e0dbf371b8b6 Mon Sep 17 00:00:00 2001 From: sternelee Date: Tue, 12 May 2026 18:32:44 +0800 Subject: [PATCH 1/3] feat(prefix_cache): add prefix-cache stability tracking inspired by Reasonix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek V4 automatic prefix caching activates only when the exact byte prefix of a request matches the prior request. This change adds a PrefixStabilityManager that detects system-prompt or tool-set drift across turns — the single biggest cause of KV cache invalidation. New module: crates/tui/src/prefix_cache.rs - PrefixFingerprint: SHA-256 fingerprint of system prompt + sorted tool names for collision-resistant identity - PrefixStabilityManager: tracks prefix stability across turns, detects drift, re-pins on change, computes stability ratio - PrefixChange: diagnostic record of what drifted (sys? tools? both?) Integration: - engine.rs: initializes PrefixStabilityManager (unpinned) at session start - turn_loop.rs: checks prefix stability before every request; emits PrefixCacheChange event on drift OR stable heartbeat - events.rs: new PrefixCacheChange event variant - session.rs: stores Option for engine-scoped lifecycle - app.rs: tracks prefix_change_count, prefix_stability_pct, prefix_checks_total, last_prefix_change_desc - ui.rs: event handler updates TUI counters; /cache command shows prefix stability stats; footer P chip shows live stability % Why only tool names (not full schema) in the fingerprint: The DeepSeek provider flattens tool definitions client-side before sending, and the serialized form is a function of the model-visible catalog — not the raw Tool struct. Tool name changes are the most reliable proxy for cache-invalidation events at this layer. 15 unit tests cover fingerprint determinism, tool-order invariance, manager lifecycle, and stability ratio computation. Closes #159 (indirect — seam_manager remains opt-in while prefix stability provides the defensive layer). --- crates/tui/src/core/engine.rs | 10 + crates/tui/src/core/engine/turn_loop.rs | 43 +++ crates/tui/src/core/events.rs | 18 + crates/tui/src/core/session.rs | 7 + crates/tui/src/main.rs | 1 + crates/tui/src/prefix_cache.rs | 487 ++++++++++++++++++++++++ crates/tui/src/tui/app.rs | 14 + crates/tui/src/tui/ui.rs | 76 +++- 8 files changed, 652 insertions(+), 4 deletions(-) create mode 100644 crates/tui/src/prefix_cache.rs diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 55fb46d7..6fa925c1 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -453,6 +453,16 @@ impl Engine { session.last_system_prompt_hash = Some(system_prompt_hash(stable_prompt.as_ref())); session.system_prompt = stable_prompt; + // Initialize prefix-cache stability monitor. + // Pins the initial fingerprint (system prompt + tool spec names) + // so subsequent turns can detect cache-invalidating drift. + let _ = session.prefix_stability.get_or_insert_with(|| { + // Use the tool registry's spec names for fingerprinting. + // At this point tool spec builders may not be registered yet, + // so we start with None — fingerprint will pin on first request. + crate::prefix_cache::PrefixStabilityManager::new_unpinned() + }); + let subagent_manager = new_shared_subagent_manager(config.workspace.clone(), config.max_subagents); let shell_manager = config diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 40518195..24f8cdfc 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -235,6 +235,49 @@ impl Engine { &self.session.messages, ); + // Check prefix-cache stability before building the request. + // This detects system-prompt or tool-set drift that would + // invalidate DeepSeek's KV prefix cache for this turn. + // Sends an event on EVERY check so the TUI can maintain + // its own counter for the stable-checks tally. + if let Some(pm) = self.session.prefix_stability.as_mut() { + let system_text = + crate::prefix_cache::system_prompt_text(self.session.system_prompt.as_ref()); + let tools_ref: Option<&[crate::models::Tool]> = active_tools.as_deref(); + match pm.check_and_update(&system_text, tools_ref) { + Err(change) => { + tracing::debug!( + target: "prefix_cache", + "{}", + change.description() + ); + let _ = self + .tx_event + .send(Event::PrefixCacheChange { + description: change.description(), + system_prompt_changed: change.system_changed, + tools_changed: change.tools_changed, + stability_pct: (pm.stability_ratio() * 100.0) as u32, + changed: true, + }) + .await; + } + Ok(_) => { + // Stable check — keep the TUI counter in sync. + let _ = self + .tx_event + .send(Event::PrefixCacheChange { + description: String::new(), + system_prompt_changed: false, + tools_changed: false, + stability_pct: (pm.stability_ratio() * 100.0) as u32, + changed: false, + }) + .await; + } + } + } + let request = MessageRequest { model: self.session.model.clone(), messages: self.messages_with_turn_metadata(), diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 41ca417f..ea338409 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -261,6 +261,24 @@ pub enum Event { blocked_network: bool, blocked_write: bool, }, + + // === Prefix-Cache Stability Events === + /// The prefix (system prompt + tool specs) changed between turns, + /// which invalidates DeepSeek's KV prefix cache. Carries diagnostics + /// for the TUI to surface. + PrefixCacheChange { + /// Human-readable description of what changed. + description: String, + /// Whether the system prompt component changed. + system_prompt_changed: bool, + /// Whether the tool set component changed. + tools_changed: bool, + /// Overall prefix stability percentage (100 = fully stable). + stability_pct: u32, + /// True when the prefix actually changed (cache invalidated). + /// False for routine stable-check heartbeats. + changed: bool, + }, } impl Event { diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index 65ea5f16..60aa9187 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -4,6 +4,7 @@ use crate::cycle_manager::CycleBriefing; use crate::models::{Message, SystemPrompt, Usage}; +use crate::prefix_cache::PrefixStabilityManager; use crate::project_context::{ProjectContext, load_project_context_with_parents}; use crate::tui::approval::ApprovalMode; use crate::working_set::WorkingSet; @@ -82,6 +83,11 @@ pub struct Session { /// Briefings produced at past cycle boundaries, in chronological order. /// Bounded growth: one entry per cycle, briefing capped at ~3,000 tokens. pub cycle_briefings: Vec, + + /// Prefix-cache stability monitor (inspired by Reasonix's Pillar 1). + /// Tracks the immutable prefix fingerprint and detects drift across turns. + /// Set during engine construction; None until the first system prompt assembly. + pub prefix_stability: Option, } /// Cumulative usage statistics for a session. @@ -155,6 +161,7 @@ impl Session { cycle_count: 0, current_cycle_started: Utc::now(), cycle_briefings: Vec::new(), + prefix_stability: None, } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 409690de..661bb7fe 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -52,6 +52,7 @@ mod project_context; mod project_doc; mod prompts; pub mod repl; +mod prefix_cache; mod retry_status; pub mod rlm; mod runtime_api; diff --git a/crates/tui/src/prefix_cache.rs b/crates/tui/src/prefix_cache.rs new file mode 100644 index 00000000..1196aea2 --- /dev/null +++ b/crates/tui/src/prefix_cache.rs @@ -0,0 +1,487 @@ +//! Prefix-cache stability manager (inspired by Reasonix's Pillar 1). +//! +//! DeepSeek's automatic prefix caching activates only when the *exact* +//! byte prefix of a request matches the prior request. Any system-prompt +//! drift, tool-list reordering, or message-rewriting busts the cache +//! for every token after the changed byte. +//! +//! This module provides a `PrefixStabilityManager` that: +//! +//! 1. **Fingerprints** the immutable prefix (system prompt + tool specs) +//! at session start, using SHA-256 for strong collision resistance. +//! 2. **Detects drift** by comparing the current prefix against the +//! pinned fingerprint before every request. +//! 3. **Diagnoses** the cause of drift — did the system prompt change? +//! Did the tool set change? Both? +//! 4. **Emits events** so the TUI can surface stability to the user. +//! +//! ## Three-region model (from Reasonix) +//! +//! ```text +//! ┌─────────────────────────────────────────┐ +//! │ IMMUTABLE PREFIX │ ← fixed for session +//! │ system + tool_specs │ cache hit candidate +//! ├─────────────────────────────────────────┤ +//! │ APPEND-ONLY HISTORY │ ← grows monotonically +//! │ [assistant₁][tool₁][assistant₂]... │ preserves prefix of prior turns +//! ├─────────────────────────────────────────┤ +//! │ LATEST USER TURN │ ← the only new content per request +//! └─────────────────────────────────────────┘ +//! ``` + +use sha2::{Digest, Sha256}; +use serde::{Deserialize, Serialize}; + +use crate::models::{SystemPrompt, Tool}; + +/// A snapshot of the immutable prefix's fingerprint. +/// +/// Two snapshots with the same `combined` hash are guaranteed to +/// produce the same byte prefix when serialized for the API. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrefixFingerprint { + /// SHA-256 of the system prompt text. + pub system_sha256: String, + /// SHA-256 of the concatenated, sorted tool names + schemas. + pub tools_sha256: String, + /// SHA-256 of system_sha256 ++ tools_sha256 (combined). + pub combined_sha256: String, +} + +impl PrefixFingerprint { + /// Compute a fingerprint from system prompt text and tool list. + pub fn compute(system_text: &str, tools: Option<&[Tool]>) -> Self { + let system_sha256 = sha256_hex(system_text.as_bytes()); + + let tools_sha256 = match tools { + Some(tools) if !tools.is_empty() => { + // Sort tool names deterministically so the hash is + // stable regardless of registration order. + let mut tool_names: Vec<&str> = tools.iter() + .map(|t| t.name.as_str()) + .collect(); + tool_names.sort(); + let joined = tool_names.join(","); + sha256_hex(joined.as_bytes()) + } + _ => sha256_hex(b""), + }; + + let combined = format!("{}:{}", system_sha256, tools_sha256); + let combined_sha256 = sha256_hex(combined.as_bytes()); + + Self { + system_sha256, + tools_sha256, + combined_sha256, + } + } +} + +/// A change record describing what drifted in the prefix. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrefixChange { + /// The old fingerprint (before the change). + pub old: PrefixFingerprint, + /// The new fingerprint (after the change). + pub new: PrefixFingerprint, + /// Whether the system prompt component changed. + pub system_changed: bool, + /// Whether the tool set component changed. + pub tools_changed: bool, +} + +#[allow(dead_code)] +impl PrefixChange { + /// Returns a human-readable description of what changed. + pub fn description(&self) -> String { + let mut parts = Vec::new(); + if self.system_changed { + parts.push("system prompt"); + } + if self.tools_changed { + parts.push("tool set"); + } + if parts.is_empty() { + return "unknown (fingerprint mismatch but no component detected)".to_string(); + } + format!("prefix cache invalidated: {} changed", parts.join(" and ")) + } + + /// Returns a short label for TUI chip display. + pub fn label(&self) -> &'static str { + if self.system_changed && self.tools_changed { + "sys+tools" + } else if self.system_changed { + "sys" + } else if self.tools_changed { + "tools" + } else { + "prefix" + } + } +} + +/// Monitors and manages prefix-cache stability across turns. +/// +/// This is the core abstraction, mirroring Reasonix's `ImmutablePrefix` +/// concept but adapted to DeepSeek-TUI's existing architecture where the +/// system prompt is rebuilt each turn and tools are registered at startup. +/// +/// Usage: +/// ```ignore +/// let mgr = PrefixStabilityManager::new(system_text, tools); +/// if mgr.check_and_update(system_text, tools) { +/// println!("Prefix is stable (cache-friendly)"); +/// } else { +/// let change = mgr.last_change().unwrap(); +/// println!("Prefix drifted: {}", change.description()); +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct PrefixStabilityManager { + /// The pinned fingerprint from session start or last stabilization. + pinned: Option, + /// The most recent fingerprint (computed during last check). + current: Option, + /// The last detected change, if any. + last_change: Option, + /// Total number of prefix changes detected this session. + change_count: u64, + /// Total number of stability checks performed. + check_count: u64, +} + +#[allow(dead_code)] +impl PrefixStabilityManager { + /// Create a new manager and immediately pin the first fingerprint. + pub fn new(system_text: &str, tools: Option<&[Tool]>) -> Self { + let fp = PrefixFingerprint::compute(system_text, tools); + Self { + pinned: Some(fp.clone()), + current: Some(fp), + last_change: None, + change_count: 0, + check_count: 1, + } + } + + /// Create a manager in "unpinned" state — no initial fingerprint. + /// Call `pin()` or `check_and_update()` to establish the baseline. + pub fn new_unpinned() -> Self { + Self { + pinned: None, + current: None, + last_change: None, + change_count: 0, + check_count: 0, + } + } + + /// Explicitly pin a fingerprint, replacing any prior pinned state. + /// Returns `true` if this is the first pin, or `false` if replacing. + pub fn pin(&mut self, system_text: &str, tools: Option<&[Tool]>) -> bool { + let fp = PrefixFingerprint::compute(system_text, tools); + let was_unpinned = self.pinned.is_none(); + self.pinned = Some(fp.clone()); + self.current = Some(fp); + self.check_count += 1; + was_unpinned + } + + /// Check whether the current prefix matches the pinned fingerprint. + /// Updates internal state and returns: + /// - `Ok(true)` if the prefix is stable (fingerprint matches pinned). + /// - `Ok(false)` if the prefix changed but was automatically re-pinned. + /// - `Err(change)` if the prefix changed; caller should surface this. + /// + /// After calling this, `last_change()` returns the detected change. + pub fn check_and_update( + &mut self, + system_text: &str, + tools: Option<&[Tool]>, + ) -> Result { + let fp = PrefixFingerprint::compute(system_text, tools); + let old_fp = std::mem::replace(&mut self.current, Some(fp.clone())); + self.check_count += 1; + + let pinned = match &self.pinned { + Some(p) => p, + None => { + // First check: pin now. + self.pinned = Some(fp); + self.last_change = None; + return Ok(true); + } + }; + + if fp.combined_sha256 == pinned.combined_sha256 { + // Stable — no change. + Ok(true) + } else { + // Change detected. + let old = old_fp.unwrap_or_else(|| pinned.clone()); + let system_changed = fp.system_sha256 != pinned.system_sha256; + let tools_changed = fp.tools_sha256 != pinned.tools_sha256; + + let change = PrefixChange { + old, + new: fp.clone(), + system_changed, + tools_changed, + }; + + self.last_change = Some(change.clone()); + self.change_count += 1; + + // Re-pin to the new prefix so subsequent checks are + // against the latest baseline. Use the original fp + // (avoid recomputing the hash — clone was for the change record). + self.pinned = Some(fp); + + Err(change) + } + } + + /// Returns the most recent prefix change, if any. + pub fn last_change(&self) -> Option<&PrefixChange> { + self.last_change.as_ref() + } + + /// Returns the pinned fingerprint. + pub fn pinned_fingerprint(&self) -> Option<&PrefixFingerprint> { + self.pinned.as_ref() + } + + /// Returns the current (most recently computed) fingerprint. + pub fn current_fingerprint(&self) -> Option<&PrefixFingerprint> { + self.current.as_ref() + } + + /// Returns the total number of prefix changes detected. + pub fn change_count(&self) -> u64 { + self.change_count + } + + /// Returns the total number of stability checks performed. + pub fn check_count(&self) -> u64 { + self.check_count + } + + /// Returns the prefix stability rate as a fraction (0.0 – 1.0). + /// 1.0 means the prefix has never changed. + pub fn stability_ratio(&self) -> f64 { + if self.check_count <= 1 { + 1.0 + } else { + let stable_checks = self.check_count - self.change_count; + stable_checks as f64 / self.check_count as f64 + } + } + + /// Returns a human-readable stability summary. + pub fn summary(&self) -> String { + let pct = self.stability_ratio() * 100.0; + let pinned_short = self + .pinned + .as_ref() + .map(|fp| { + if fp.combined_sha256.len() >= 12 { + &fp.combined_sha256[..12] + } else { + &fp.combined_sha256 + } + }) + .unwrap_or("none"); + + format!( + "Prefix stability: {pct:.1}% ({stable}/{total} checks stable) | fingerprint: {pinned_short} | changes: {changes}", + pct = pct, + stable = self.check_count.saturating_sub(self.change_count), + total = self.check_count, + pinned_short = pinned_short, + changes = self.change_count, + ) + } +} + +/// Compute the SHA-256 hex digest of a byte slice. +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +/// Extract the system prompt text from an optional SystemPrompt, +/// returning an owned String. This is used for prefix fingerprinting +/// and avoids lifetime/leak issues with the rare SystemPrompt::Blocks case. +pub fn system_prompt_text(system: Option<&SystemPrompt>) -> String { + match system { + Some(SystemPrompt::Text(text)) => text.clone(), + Some(SystemPrompt::Blocks(blocks)) => { + let mut text = String::new(); + for block in blocks { + text.push_str(&block.text); + text.push('\n'); + } + text + } + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + + fn make_tool(name: &str) -> Tool { + Tool { + name: name.to_string(), + description: String::new(), + input_schema: serde_json::Value::Null, + tool_type: None, + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + } + } + + #[test] + fn same_prefix_produces_same_fingerprint() { + let a = PrefixFingerprint::compute("hello world", None); + let b = PrefixFingerprint::compute("hello world", None); + assert_eq!(a.combined_sha256, b.combined_sha256); + } + + #[test] + fn different_system_produces_different_fingerprint() { + let a = PrefixFingerprint::compute("hello", None); + let b = PrefixFingerprint::compute("world", None); + assert_ne!(a.combined_sha256, b.combined_sha256); + } + + #[test] + fn tool_order_does_not_affect_fingerprint() { + let tools_a = vec![make_tool("read_file"), make_tool("write_file")]; + let tools_b = vec![make_tool("write_file"), make_tool("read_file")]; + let a = PrefixFingerprint::compute("system", Some(&tools_a)); + let b = PrefixFingerprint::compute("system", Some(&tools_b)); + assert_eq!(a.combined_sha256, b.combined_sha256); + } + + #[test] + fn different_tools_produce_different_fingerprint() { + let tools_a = vec![make_tool("read_file")]; + let tools_b = vec![make_tool("write_file")]; + let a = PrefixFingerprint::compute("system", Some(&tools_a)); + let b = PrefixFingerprint::compute("system", Some(&tools_b)); + assert_ne!(a.combined_sha256, b.combined_sha256); + } + + #[test] + fn manager_starts_stable() { + let mut mgr = PrefixStabilityManager::new("system prompt", None); + assert!(mgr.check_and_update("system prompt", None).unwrap()); + assert_eq!(mgr.change_count(), 0); + } + + #[test] + fn manager_detects_change() { + let mut mgr = PrefixStabilityManager::new("system prompt", None); + let result = mgr.check_and_update("different prompt", None); + assert!(result.is_err()); + assert_eq!(mgr.change_count(), 1); + let change = mgr.last_change().unwrap(); + assert!(change.system_changed); + assert!(!change.tools_changed); + } + + #[test] + fn manager_detects_tool_change() { + let tools_a = vec![make_tool("read_file")]; + let tools_b = vec![make_tool("write_file")]; + let mut mgr = PrefixStabilityManager::new("system", Some(&tools_a)); + let result = mgr.check_and_update("system", Some(&tools_b)); + assert!(result.is_err()); + let change = mgr.last_change().unwrap(); + assert!(!change.system_changed); + assert!(change.tools_changed); + } + + #[test] + fn manager_re_pins_after_change() { + let mut mgr = PrefixStabilityManager::new("old", None); + let _ = mgr.check_and_update("new", None); + // After re-pin, the new "new" should be stable. + assert!(mgr.check_and_update("new", None).unwrap()); + assert_eq!(mgr.change_count(), 1); + } + + #[test] + fn stability_ratio_is_one_for_no_changes() { + let mut mgr = PrefixStabilityManager::new("hello", None); + mgr.check_and_update("hello", None).unwrap(); + mgr.check_and_update("hello", None).unwrap(); + assert!((mgr.stability_ratio() - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn stability_ratio_reflects_change_rate() { + let mut mgr = PrefixStabilityManager::new("hello", None); + mgr.check_and_update("hello", None).unwrap(); // check 2: stable + let _ = mgr.check_and_update("world", None); // check 3: changed + mgr.check_and_update("world", None).unwrap(); // check 4: stable + // 3 stable out of 4 checks = 0.75 + assert!((mgr.stability_ratio() - 0.75).abs() < 0.01); + } + + #[test] + fn empty_tools_and_none_tools_produce_same_hash() { + let empty = PrefixFingerprint::compute("system", Some(&[])); + let none = PrefixFingerprint::compute("system", None); + // Both should produce sha256(b"") for the tool component + assert_eq!(empty.tools_sha256, none.tools_sha256); + } + + #[test] + fn empty_system_produces_sha256_of_empty_string() { + let fp = PrefixFingerprint::compute("", None); + let expected = sha256_hex(b""); + assert_eq!(fp.system_sha256, expected); + } + + #[test] + fn prefix_change_description_is_informative() { + let old = PrefixFingerprint::compute("old", None); + let new = PrefixFingerprint::compute("new", None); + let change = PrefixChange { + old, + new, + system_changed: true, + tools_changed: false, + }; + assert_eq!(change.description(), "prefix cache invalidated: system prompt changed"); + assert_eq!(change.label(), "sys"); + } + + #[test] + fn new_unpinned_has_no_change_history() { + let mut mgr = PrefixStabilityManager::new_unpinned(); + assert!(mgr.pinned_fingerprint().is_none()); + assert!(mgr.current_fingerprint().is_none()); + assert!(mgr.last_change().is_none()); + assert_eq!(mgr.change_count(), 0); + assert_eq!(mgr.check_count(), 0); + // First check should pin automatically. + assert!(mgr.check_and_update("hello", None).unwrap()); + assert!(mgr.pinned_fingerprint().is_some()); + } + + #[test] + fn system_prompt_text_returns_empty_for_none() { + assert_eq!(system_prompt_text(None), ""); + } +} diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index ed47ba03..91d5da0d 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1053,6 +1053,16 @@ pub struct App { /// Used by `/cycles` and `/cycle ` slash commands. pub cycle_briefings: Vec, + // === Prefix-Cache Stability Tracking === + /// Number of times the prefix (system prompt + tool specs) has changed. + pub prefix_change_count: u64, + /// Total number of prefix stability checks performed. + pub prefix_checks_total: u64, + /// Current prefix stability percentage, if known. + pub prefix_stability_pct: Option, + /// Description of the last prefix change, if any. + pub last_prefix_change_desc: Option, + /// Active cycle configuration (token threshold, briefing cap, per-model /// overrides). Loaded from config and forwarded to the engine. pub cycle: CycleConfig, @@ -1553,6 +1563,10 @@ impl App { quit_armed_until: None, cycle_count: 0, cycle_briefings: Vec::new(), + prefix_change_count: 0, + prefix_checks_total: 0, + prefix_stability_pct: None, + last_prefix_change_desc: None, cycle: CycleConfig::default(), collapsed_cells: HashSet::new(), collapsed_cell_map: Vec::new(), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d71d11b0..a08179e3 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1185,6 +1185,22 @@ async fn run_event_loop( EngineEvent::CoherenceState { state, .. } => { app.coherence_state = state; } + EngineEvent::PrefixCacheChange { + description, + stability_pct, + changed, + .. + } => { + app.prefix_checks_total = app.prefix_checks_total.saturating_add(1); + app.prefix_stability_pct = Some(stability_pct); + if changed { + app.prefix_change_count = + app.prefix_change_count.saturating_add(1); + if !description.is_empty() { + app.last_prefix_change_desc = Some(description); + } + } + } EngineEvent::CapacityDecision { .. } => { // Telemetry-only event. Surface actual interventions and failures // instead of replacing the footer with no-op guardrail chatter. @@ -3163,6 +3179,34 @@ fn format_cache_warmup_result(usage: &Usage) -> String { ) } +/// Format prefix stability info for the TUI footer chip. +fn format_prefix_stability_chip(app: &App) -> Option<(String, ratatui::style::Color)> { + let pct = app.prefix_stability_pct?; + let changes = app.prefix_change_count; + + let color = if changes == 0 { + // Perfect stability: green + ratatui::style::Color::Green + } else if pct >= 95 { + // Excellent: green + ratatui::style::Color::Green + } else if pct >= 80 { + // Good: yellow + ratatui::style::Color::Yellow + } else { + // Poor: red — cache is churning + ratatui::style::Color::Red + }; + + let label = if changes == 0 { + format!("P {}", pct) + } else { + format!("P {} ({} drift)", pct, changes) + }; + + Some((label, color)) +} + fn format_available_models_message(current_model: &str, models: &[String]) -> String { let mut lines = vec![format!("Available models ({})", models.len())]; for model in models { @@ -4795,9 +4839,25 @@ async fn apply_command_result( app.status_message = Some("Warming DeepSeek cache...".to_string()); match run_cache_warmup(app, config).await { Ok(usage) => { - let message = format_cache_warmup_result(&usage); + let mut message = format_cache_warmup_result(&usage); + // Append prefix-cache stability info. + if app.prefix_checks_total > 0 { + let changes = app.prefix_change_count; + let total = app.prefix_checks_total; + let stable = total.saturating_sub(changes); + let pct = app.prefix_stability_pct + .map(|p| format!("{p}%")) + .unwrap_or_else(|| "--".to_string()); + message.push_str(&format!( + "\n\nPrefix stability: {pct} ({stable}/{total} checks stable, {changes} change{})", + if changes == 1 { "" } else { "s" } + )); + if let Some(ref desc) = app.last_prefix_change_desc { + message.push_str(&format!("\nLast prefix change: {desc}")); + } + } app.add_message(HistoryCell::System { - content: message.clone(), + content: message, }); app.status_message = Some("Cache warmup complete".to_string()); } @@ -7399,20 +7459,28 @@ fn should_show_footer_cost(displayed_cost: f64) -> bool { fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec> { // Context % is already shown in the header signal bar — don't // duplicate it in the footer. The footer carries unique info only: - // coherence, in-flight sub-agents, reasoning replay tokens, cache hit - // rate, and session cost. + // prefix stability, coherence, in-flight sub-agents, reasoning + // replay tokens, cache hit rate, and session cost. let coherence_spans = footer_coherence_spans(app); let agents_spans = crate::tui::widgets::footer_agents_chip(running_agent_count(app), app.ui_locale); let replay_spans = footer_reasoning_replay_spans(app); let cache_spans = footer_cache_spans(app); let cost_spans = footer_cost_spans(app); + let prefix_spans = app.prefix_stability_pct + .map(|_| { + let (label, color) = format_prefix_stability_chip(app) + .unwrap_or(("P --".to_string(), ratatui::style::Color::DarkGray)); + vec![Span::styled(label, Style::default().fg(color))] + }) + .unwrap_or_default(); let parts: Vec<&Vec>> = [ &coherence_spans, &agents_spans, &replay_spans, &cache_spans, + &prefix_spans, &cost_spans, ] .iter() From 7d6c9a712491ca1dac05bfda668c3673208ad3f4 Mon Sep 17 00:00:00 2001 From: sternelee Date: Tue, 12 May 2026 18:51:43 +0800 Subject: [PATCH 2/3] fix(prefix_cache): address Copilot & Gemini Code Assist review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine.rs: correct "Pins initial fingerprint" comment to reflect lazy-pin behavior (new_unpinned defers pinning to first turn) - prefix_cache.rs: stop pin() and new() from inflating check_count so stability_ratio() only reflects check_and_update calls - turn_loop.rs: use .round() instead of truncating float→u32 cast so 99.9% displays as "100" not "99" - tests: update stability_ratio expected values to match corrected counting (check_count starts at 0) --- crates/tui/src/core/engine.rs | 8 ++++--- crates/tui/src/core/engine/turn_loop.rs | 4 ++-- crates/tui/src/prefix_cache.rs | 29 ++++++++++++++++--------- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 6fa925c1..cee3b417 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -453,9 +453,11 @@ impl Engine { session.last_system_prompt_hash = Some(system_prompt_hash(stable_prompt.as_ref())); session.system_prompt = stable_prompt; - // Initialize prefix-cache stability monitor. - // Pins the initial fingerprint (system prompt + tool spec names) - // so subsequent turns can detect cache-invalidating drift. + // Initialize prefix-cache stability monitor (lazy-pin). + // The system prompt is available now but the tool catalog isn't + // fully built until the first turn, so we start unpinned. The + // first `check_and_update` call in the turn loop will pin the + // fingerprint automatically. let _ = session.prefix_stability.get_or_insert_with(|| { // Use the tool registry's spec names for fingerprinting. // At this point tool spec builders may not be registered yet, diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 24f8cdfc..cd130d77 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -257,7 +257,7 @@ impl Engine { description: change.description(), system_prompt_changed: change.system_changed, tools_changed: change.tools_changed, - stability_pct: (pm.stability_ratio() * 100.0) as u32, + stability_pct: (pm.stability_ratio() * 100.0).round() as u32, changed: true, }) .await; @@ -270,7 +270,7 @@ impl Engine { description: String::new(), system_prompt_changed: false, tools_changed: false, - stability_pct: (pm.stability_ratio() * 100.0) as u32, + stability_pct: (pm.stability_ratio() * 100.0).round() as u32, changed: false, }) .await; diff --git a/crates/tui/src/prefix_cache.rs b/crates/tui/src/prefix_cache.rs index 1196aea2..0d221300 100644 --- a/crates/tui/src/prefix_cache.rs +++ b/crates/tui/src/prefix_cache.rs @@ -162,7 +162,7 @@ impl PrefixStabilityManager { current: Some(fp), last_change: None, change_count: 0, - check_count: 1, + check_count: 0, } } @@ -180,12 +180,13 @@ impl PrefixStabilityManager { /// Explicitly pin a fingerprint, replacing any prior pinned state. /// Returns `true` if this is the first pin, or `false` if replacing. + /// Note: does NOT increment `check_count` — that counter is reserved + /// for `check_and_update` calls so `stability_ratio()` stays accurate. pub fn pin(&mut self, system_text: &str, tools: Option<&[Tool]>) -> bool { let fp = PrefixFingerprint::compute(system_text, tools); let was_unpinned = self.pinned.is_none(); self.pinned = Some(fp.clone()); self.current = Some(fp); - self.check_count += 1; was_unpinned } @@ -269,9 +270,10 @@ impl PrefixStabilityManager { } /// Returns the prefix stability rate as a fraction (0.0 – 1.0). - /// 1.0 means the prefix has never changed. + /// 1.0 means the prefix has never changed. Returns 1.0 when no + /// checks have been performed (to avoid division by zero). pub fn stability_ratio(&self) -> f64 { - if self.check_count <= 1 { + if self.check_count == 0 { 1.0 } else { let stable_checks = self.check_count - self.change_count; @@ -386,6 +388,7 @@ mod tests { let mut mgr = PrefixStabilityManager::new("system prompt", None); assert!(mgr.check_and_update("system prompt", None).unwrap()); assert_eq!(mgr.change_count(), 0); + assert_eq!(mgr.check_count(), 1); } #[test] @@ -426,16 +429,21 @@ mod tests { mgr.check_and_update("hello", None).unwrap(); mgr.check_and_update("hello", None).unwrap(); assert!((mgr.stability_ratio() - 1.0).abs() < f64::EPSILON); + assert_eq!(mgr.check_count(), 2); + assert_eq!(mgr.change_count(), 0); } #[test] fn stability_ratio_reflects_change_rate() { let mut mgr = PrefixStabilityManager::new("hello", None); - mgr.check_and_update("hello", None).unwrap(); // check 2: stable - let _ = mgr.check_and_update("world", None); // check 3: changed - mgr.check_and_update("world", None).unwrap(); // check 4: stable - // 3 stable out of 4 checks = 0.75 - assert!((mgr.stability_ratio() - 0.75).abs() < 0.01); + mgr.check_and_update("hello", None).unwrap(); // check 1: stable + let _ = mgr.check_and_update("world", None); // check 2: changed + mgr.check_and_update("world", None).unwrap(); // check 3: stable + // 2 stable out of 3 checks = 0.666... + // (check_count=0 at start, so 3 checks: 3 checks - 1 change = 2 stable) + assert!((mgr.stability_ratio() - 2.0 / 3.0).abs() < 0.01); + assert_eq!(mgr.check_count(), 3); + assert_eq!(mgr.change_count(), 1); } #[test] @@ -475,9 +483,10 @@ mod tests { assert!(mgr.last_change().is_none()); assert_eq!(mgr.change_count(), 0); assert_eq!(mgr.check_count(), 0); - // First check should pin automatically. + // First check should pin automatically and count as a check. assert!(mgr.check_and_update("hello", None).unwrap()); assert!(mgr.pinned_fingerprint().is_some()); + assert_eq!(mgr.check_count(), 1); } #[test] From 9d0face699219f14e6c6d6922d0907270d7b0e21 Mon Sep 17 00:00:00 2001 From: sternelee Date: Tue, 12 May 2026 19:18:31 +0800 Subject: [PATCH 3/3] fix(prefix_cache): use "changes" instead of "drift" for footer chip pluralisation Addresses Copilot review comment on ui.rs:3204: "2 drift" reads awkwardly as a plural; "2 changes" is grammatical for both singular and plural values. --- crates/tui/src/tui/ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index a08179e3..8a4725d9 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3201,7 +3201,7 @@ fn format_prefix_stability_chip(app: &App) -> Option<(String, ratatui::style::Co let label = if changes == 0 { format!("P {}", pct) } else { - format!("P {} ({} drift)", pct, changes) + format!("P {} ({} change{})", pct, changes, if changes == 1 { "" } else { "s" }) }; Some((label, color))