use crate::{
core::{
cache_warmer::{CacheRetention, read_cache_retention},
extensions::{
Extension, ExtensionHook, ExtensionMode, ExtensionTool, ExtensionUiRequest,
ForkProjectInfo, UiNotifyLevel, request_ui,
},
model_resolver,
provider::AgentMessage,
session_manager::Session,
},
extensions::{EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_CACHE_WATCH},
utils::time::{format_duration, now_ms},
};
use serde_json::Value;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
const EXT: &str = "cache-watch";
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static CACHE_WATCH_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_CACHE_WATCH,
make: || -> Arc<dyn Extension> { Arc::new(CacheWatch) },
};
#[derive(Debug, Clone)]
struct CacheStamp {
provider: String,
model: String,
at: u64,
written: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct UsageView {
input: u64,
read: u64,
write: u64,
}
#[derive(Default)]
struct State {
last_write: Option<CacheStamp>,
cold_warned_at: Option<u64>,
miss_warned_at: Option<u64>,
}
fn state() -> &'static Mutex<State> {
static S: OnceLock<Mutex<State>> = OnceLock::new();
S.get_or_init(|| Mutex::new(State::default()))
}
fn lock() -> MutexGuard<'static, State> {
state().lock().unwrap_or_else(|e| e.into_inner())
}
fn reset() {
*lock() = State::default();
}
pub struct CacheWatch;
impl Extension for CacheWatch {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"Warn when the prompt cache goes cold or misses unexpectedly (cache-watch)."
}
fn fork_project(&self) -> Option<ForkProjectInfo> {
Some(ForkProjectInfo {
plugin_name: "jcode".to_string(),
plugin_version: "0.94.0".to_string(),
url: "https://github.com/1jehuang/jcode".to_string(),
})
}
fn default_enabled(&self) -> bool {
false
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::AgentEvent, ExtensionHook::UserPrompt]
}
fn on_agent_event(&self, event: &Value) {
if event.get("type").and_then(Value::as_str) == Some("turn_end") {
handle_turn_end(event);
}
}
fn on_user_submit(&self, _text: &str, busy: bool) {
if !busy {
warn_if_cold();
}
}
fn on_session_start(&self, _cwd: &str, _messages: &[AgentMessage], _session: Option<&Session>) {
reset();
}
fn on_session_switched(&self, _session_path: Option<&str>, _messages: &[AgentMessage]) {
reset();
}
fn on_enabled_changed(&self, enabled: bool) {
if !enabled {
reset();
}
}
}
fn handle_turn_end(event: &Value) {
let Some((view, provider, model)) = usage_view(event) else {
return;
};
if provider.is_empty() || model.is_empty() {
return;
}
let now = now_ms();
let mut st = lock();
if let Some(ttl) = cache_ttl_ms(&provider, &model)
&& let Some(last) = st.last_write.clone()
&& last.model == model
&& is_unexpected_miss(&view, last.written, elapsed_ms(last.at, now), ttl)
&& st.miss_warned_at != Some(last.at)
{
let text = format!(
"Unexpected prompt cache miss: the previous request wrote {} tokens of cache {} ago (TTL {}) but this one read none back — the system prompt, tool set, or message prefix likely changed.",
last.written,
format_duration(elapsed_ms(last.at, now)),
format_duration(ttl)
);
st.miss_warned_at = Some(last.at);
drop(st);
notify(text, UiNotifyLevel::Warning);
return;
}
if view.write > 0 {
st.last_write = Some(CacheStamp {
provider,
model,
at: now,
written: view.write,
});
st.cold_warned_at = None;
st.miss_warned_at = None;
} else if view.read > 0 {
st.cold_warned_at = None;
}
}
fn warn_if_cold() {
let now = now_ms();
let mut st = lock();
let Some(last) = st.last_write.clone() else {
return;
};
let Some(ttl) = cache_ttl_ms(&last.provider, &last.model) else {
return;
};
if !is_cold(elapsed_ms(last.at, now), ttl) || st.cold_warned_at == Some(last.at) {
return;
}
let text = format!(
"Prompt cache went cold: the last cache write was {} ago (TTL {}), so the next request re-reads the whole context at full input price.",
format_duration(elapsed_ms(last.at, now)),
format_duration(ttl)
);
st.cold_warned_at = Some(last.at);
drop(st);
notify(text, UiNotifyLevel::Warning);
}
fn notify(text: String, level: UiNotifyLevel) {
request_ui(ExtensionUiRequest::Notify { text, level });
}
fn usage_view(event: &Value) -> Option<(UsageView, String, String)> {
let message = event.get("message")?;
let usage = message.get("usage")?;
let view = UsageView {
input: usage.get("input").and_then(Value::as_u64).unwrap_or(0),
read: usage.get("cache_read").and_then(Value::as_u64).unwrap_or(0),
write: usage
.get("cache_write")
.and_then(Value::as_u64)
.unwrap_or(0),
};
let provider = message
.get("provider")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let model = message
.get("model")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
Some((view, provider, model))
}
fn cache_ttl_ms(provider: &str, model: &str) -> Option<u64> {
let tier = match read_cache_retention() {
CacheRetention::None => return None,
CacheRetention::Short => "short",
CacheRetention::Long => "long",
};
model_resolver::provider_models(provider)
.iter()
.find(|m| m.get("id").and_then(Value::as_str) == Some(model))
.and_then(|m| m.get("promptCache"))
.and_then(|p| p.get(tier))
.and_then(Value::as_u64)
.filter(|s| *s > 0)
.map(|s| s.saturating_mul(1000))
}
fn elapsed_ms(at: u64, now: u64) -> u64 {
now.saturating_sub(at)
}
fn is_cold(elapsed_ms: u64, ttl_ms: u64) -> bool {
elapsed_ms > ttl_ms
}
fn is_unexpected_miss(view: &UsageView, last_written: u64, elapsed_ms: u64, ttl_ms: u64) -> bool {
view.read == 0 && view.write > 0 && last_written > 0 && !is_cold(elapsed_ms, ttl_ms)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
core::extensions::take_pending_ui,
test_support::{AUTH_TEST_LOCK, AgentDirGuard},
};
use serde_json::json;
fn guard() -> (MutexGuard<'static, ()>, AgentDirGuard) {
let lock = AUTH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
(lock, AgentDirGuard::temp())
}
fn drain_ui() {
while take_pending_ui().is_some() {}
}
fn turn_end(provider: &str, model: &str, read: u64, write: u64) -> Value {
json!({
"type": "turn_end",
"message": {
"role": "assistant",
"provider": provider,
"model": model,
"usage": { "input": 10, "output": 5, "cache_read": read, "cache_write": write }
}
})
}
#[test]
fn cold_boundary_is_strictly_greater_than_ttl() {
assert!(!is_cold(300_000, 300_000), "恰好到 TTL 还不算冷");
assert!(is_cold(300_001, 300_000));
}
#[test]
fn unexpected_miss_requires_a_rewrite_within_ttl() {
let rewrite = UsageView {
input: 10,
read: 0,
write: 100,
};
assert!(is_unexpected_miss(&rewrite, 100, 1_000, 300_000));
let hit = UsageView {
input: 10,
read: 100,
write: 0,
};
assert!(!is_unexpected_miss(&hit, 100, 1_000, 300_000), "命中不算");
let no_write = UsageView {
input: 10,
read: 0,
write: 0,
};
assert!(
!is_unexpected_miss(&no_write, 100, 1_000, 300_000),
"既没读也没写不是异常"
);
assert!(
!is_unexpected_miss(&rewrite, 0, 1_000, 300_000),
"上一轮没写过缓存就没有「本应命中」"
);
assert!(
!is_unexpected_miss(&rewrite, 100, 400_000, 300_000),
"超 TTL 属于冷,不算意外未命中"
);
}
#[test]
fn duration_switches_to_minutes() {
assert_eq!(format_duration(0), "0ms");
assert_eq!(format_duration(59_999), "60s");
assert_eq!(format_duration(60_000), "1m0s");
assert_eq!(format_duration(300_000), "5m0s");
}
#[test]
fn ttl_lookup_is_none_without_catalog_entry() {
assert_eq!(cache_ttl_ms("no-such-provider", "no-such-model"), None);
}
#[test]
fn usage_view_reads_snake_case_fields() {
let (view, provider, model) = usage_view(&turn_end("anthropic", "claude-x", 7, 9)).unwrap();
assert_eq!(
view,
UsageView {
input: 10,
read: 7,
write: 9
}
);
assert_eq!(provider, "anthropic");
assert_eq!(model, "claude-x");
assert!(usage_view(&json!({ "type": "turn_end" })).is_none());
}
#[test]
fn turn_end_records_cache_writes() {
let (_g, _ad) = guard();
reset();
drain_ui();
handle_turn_end(&turn_end("anthropic", "claude-x", 0, 1234));
let st = lock();
let last = st.last_write.as_ref().expect("写入应被记录");
assert_eq!(last.written, 1234);
assert_eq!(last.provider, "anthropic");
drop(st);
assert!(take_pending_ui().is_none(), "首次写入不该有提示");
}
#[test]
fn session_switch_resets_the_timeline() {
let (_g, _ad) = guard();
drain_ui();
handle_turn_end(&turn_end("anthropic", "claude-x", 0, 10));
assert!(lock().last_write.is_some());
CacheWatch.on_session_switched(None, &[]);
assert!(lock().last_write.is_none());
handle_turn_end(&turn_end("anthropic", "claude-x", 0, 10));
CacheWatch.on_enabled_changed(false);
assert!(lock().last_write.is_none());
}
#[test]
fn cold_warning_fires_once_per_write() {
let (_g, _ad) = guard();
reset();
drain_ui();
let mut st = lock();
st.last_write = Some(CacheStamp {
provider: "no-such-provider".to_string(),
model: "no-such-model".to_string(),
at: now_ms(),
written: 500,
});
drop(st);
warn_if_cold();
assert!(take_pending_ui().is_none(), "拿不到 TTL 时不该提示");
}
#[test]
fn cold_warning_fires_and_is_not_repeated() {
let (_g, _ad) = guard();
reset();
drain_ui();
let model = "claude-sonnet-4-5";
assert!(
cache_ttl_ms("anthropic", model) == Some(300_000),
"目录应给出 300s 的 short TTL"
);
lock().last_write = Some(CacheStamp {
provider: "anthropic".to_string(),
model: model.to_string(),
at: now_ms().saturating_sub(400_000),
written: 500,
});
warn_if_cold();
match take_pending_ui().expect("超 TTL 应提示缓存已冷") {
ExtensionUiRequest::Notify { text, level } => {
assert!(text.contains("went cold"), "{text}");
assert_eq!(level, UiNotifyLevel::Warning);
}
other => panic!("期望 Notify,得到 {other:?}"),
}
warn_if_cold();
assert!(take_pending_ui().is_none(), "同一份缓存只提示一次");
handle_turn_end(&turn_end("anthropic", model, 0, 42));
warn_if_cold();
assert!(take_pending_ui().is_none(), "刚写过缓存不该提示冷");
assert!(lock().cold_warned_at.is_none());
}
#[test]
fn unexpected_miss_is_reported_once() {
let (_g, _ad) = guard();
reset();
drain_ui();
let model = "claude-sonnet-4-5";
lock().last_write = Some(CacheStamp {
provider: "anthropic".to_string(),
model: model.to_string(),
at: now_ms().saturating_sub(1_000),
written: 900,
});
handle_turn_end(&turn_end("anthropic", model, 0, 800));
let level = loop {
match take_pending_ui() {
Some(ExtensionUiRequest::Notify { text, level }) => {
assert!(text.contains("Unexpected prompt cache miss"), "{text}");
break level;
}
Some(_) => continue,
None => panic!("应提示意外未命中"),
}
};
assert_eq!(level, UiNotifyLevel::Warning);
handle_turn_end(&turn_end("anthropic", model, 900, 0));
while let Some(request) = take_pending_ui() {
if let ExtensionUiRequest::Notify { text, .. } = request {
assert!(
!text.contains("Unexpected prompt cache miss"),
"命中不该提示: {text}"
);
}
}
}
#[test]
fn fork_project_reports_jcode_source() {
let info = CacheWatch
.fork_project()
.expect("cache-watch 应声明 fork 来源");
assert_eq!(info.plugin_name, "jcode");
assert_eq!(info.plugin_version, "0.94.0");
assert_eq!(info.url, "https://github.com/1jehuang/jcode");
}
#[test]
fn extension_declares_hooks_and_stays_optional() {
assert_eq!(CacheWatch.name(), EXT);
assert!(!CacheWatch.default_enabled());
assert_eq!(
CacheWatch.modes(),
vec![ExtensionMode::Dev, ExtensionMode::Creator]
);
assert_eq!(
CacheWatch.hooks(),
vec![ExtensionHook::AgentEvent, ExtensionHook::UserPrompt]
);
assert!(CacheWatch.tools().is_empty());
}
}