Merge remote-tracking branch 'origin/pr/1517' into work/v0.8.34
This commit is contained in:
@@ -536,6 +536,18 @@ 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 (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,
|
||||
// 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
|
||||
|
||||
@@ -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).round() 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).round() as u32,
|
||||
changed: false,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let request = MessageRequest {
|
||||
model: self.session.model.clone(),
|
||||
messages: self.messages_with_turn_metadata(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<CycleBriefing>,
|
||||
|
||||
/// 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<PrefixStabilityManager>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ mod memory;
|
||||
mod models;
|
||||
mod network_policy;
|
||||
mod palette;
|
||||
mod prefix_cache;
|
||||
mod pricing;
|
||||
mod project_context;
|
||||
mod project_doc;
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
//! 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 serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
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.
|
||||
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<PrefixFingerprint>,
|
||||
/// The most recent fingerprint (computed during last check).
|
||||
current: Option<PrefixFingerprint>,
|
||||
/// The last detected change, if any.
|
||||
last_change: Option<PrefixChange>,
|
||||
/// 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: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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);
|
||||
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<bool, PrefixChange> {
|
||||
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. Returns 1.0 when no
|
||||
/// checks have been performed (to avoid division by zero).
|
||||
pub fn stability_ratio(&self) -> f64 {
|
||||
if self.check_count == 0 {
|
||||
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);
|
||||
assert_eq!(mgr.check_count(), 1);
|
||||
}
|
||||
|
||||
#[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);
|
||||
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 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]
|
||||
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 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]
|
||||
fn system_prompt_text_returns_empty_for_none() {
|
||||
assert_eq!(system_prompt_text(None), "");
|
||||
}
|
||||
}
|
||||
@@ -1084,6 +1084,16 @@ pub struct App {
|
||||
/// Used by `/cycles` and `/cycle <n>` slash commands.
|
||||
pub cycle_briefings: Vec<CycleBriefing>,
|
||||
|
||||
// === 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<u32>,
|
||||
/// Description of the last prefix change, if any.
|
||||
pub last_prefix_change_desc: Option<String>,
|
||||
|
||||
/// Active cycle configuration (token threshold, briefing cap, per-model
|
||||
/// overrides). Loaded from config and forwarded to the engine.
|
||||
pub cycle: CycleConfig,
|
||||
@@ -1582,6 +1592,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(),
|
||||
|
||||
@@ -1449,6 +1449,21 @@ 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.
|
||||
@@ -3472,6 +3487,37 @@ 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 {pct}% ({changes} change{})",
|
||||
if changes == 1 { "" } else { "s" }
|
||||
)
|
||||
};
|
||||
|
||||
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 {
|
||||
@@ -5241,10 +5287,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);
|
||||
app.add_message(HistoryCell::System {
|
||||
content: message.clone(),
|
||||
});
|
||||
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 });
|
||||
app.status_message = Some("Cache warmup complete".to_string());
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -8038,20 +8099,29 @@ fn should_show_footer_cost(displayed_cost: f64) -> bool {
|
||||
fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
|
||||
// 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<Span<'static>>> = [
|
||||
&coherence_spans,
|
||||
&agents_spans,
|
||||
&replay_spans,
|
||||
&cache_spans,
|
||||
&prefix_spans,
|
||||
&cost_spans,
|
||||
]
|
||||
.iter()
|
||||
|
||||
Reference in New Issue
Block a user