Skip to main content

yapi_core/agent_session/
cache_warmer.rs

1//! Prompt cache warming: keeps the prompt cache entry of the session's latest
2//! request alive by sending the request again with a one-token output cap
3//! before the entry expires, while that is expected to save money.
4//!
5//! Port of `packages/coding-agent/src/core/cache-warmer.ts` in pi `v1.0.0`.
6
7use std::time::{Duration, SystemTime};
8
9use serde_json::Value;
10use tokio_util::sync::CancellationToken;
11use yapi_ai::stream::{CacheRetention, Request, RequestHooks};
12use yapi_types::event::AgentEvent;
13use yapi_types::extension_event::ExtensionEvent;
14use yapi_types::js;
15use yapi_types::message::{Message, StopReason, Usage};
16use yapi_types::model::{AnthropicMessagesCompat, Model};
17use yapi_types::session::{FileEntry, UsageEntry};
18use yapi_types::settings::CacheWarming;
19use yapi_types::sync::lock;
20
21use super::AgentSession;
22
23/// Streaming warming never continues past this long after the real request
24/// that started it.
25const MAX_WARMING_AGE: Duration = Duration::from_secs(60 * 60);
26/// Idle warming has a shorter horizon because continuation estimates become
27/// less reliable with age.
28const MAX_IDLE_WARMING_AGE: Duration = Duration::from_secs(30 * 60);
29/// A refresh is sent only when it is expected to save at least this many
30/// dollars.
31const MINIMUM_EXPECTED_SAVINGS: f64 = 0.05;
32/// Chance that a real request arrives before the cache entry expires while
33/// the agent sits idle, as pi measured it.
34const IDLE_CONTINUATION_PROBABILITY: f64 = 0.15;
35
36/// The wall clock, as pi's `Date.now()`. Unlike the timer's clock it keeps
37/// running while the machine sleeps, so a refresh that comes due during a
38/// sleep is known to be late afterwards.
39#[cfg(not(test))]
40fn now() -> SystemTime {
41    SystemTime::now()
42}
43
44#[cfg(test)]
45use tests::now;
46
47/// When to refresh an entry that lives `ttl_ms`: at 90% of its lifetime,
48/// keeping at least 10s of margin.
49fn delay_ms(ttl_ms: f64) -> Option<u64> {
50    (ttl_ms > 10_000.0).then(|| (ttl_ms * 0.9).min(ttl_ms - 10_000.0).floor().max(1.0) as u64)
51}
52
53/// Lifetime of the prompt cache entry `request` writes, from the model's
54/// `promptCache` tier for the retention the request used.
55fn ttl_ms(request: &Request) -> Option<f64> {
56    let cache = request.model.prompt_cache.as_ref()?;
57    let seconds = match request.options.resolved_cache_retention() {
58        CacheRetention::None => None,
59        CacheRetention::Short => cache.short,
60        CacheRetention::Long => cache.long,
61    }?;
62    Some(seconds * 1000.0)
63}
64
65/// Whether replaying `request` with a one-token output cap leaves its cache
66/// entry untouched. Anthropic's budget-based thinking derives its budget from
67/// the output cap, and Anthropic keys the message cache on the budget.
68fn replayable(request: &Request) -> bool {
69    if request.options.reasoning.is_none() || request.model.api != "anthropic-messages" {
70        return true;
71    }
72    request
73        .model
74        .compat::<AnthropicMessagesCompat>()
75        .force_adaptive_thinking
76        == Some(true)
77}
78
79/// Prompt size of the latest assistant message on the branch, as the
80/// provider reported it.
81fn last_prompt_tokens(branch: &[&FileEntry]) -> u64 {
82    branch
83        .iter()
84        .rev()
85        .find_map(|entry| match entry {
86            FileEntry::Message(entry) => match &entry.message {
87                Message::Assistant(assistant) => Some(&assistant.usage),
88                _ => None,
89            },
90            _ => None,
91        })
92        .map_or(0, |usage| {
93            usage.input + usage.cache_read + usage.cache_write
94        })
95}
96
97fn price(model: &Model, mut usage: Usage) -> f64 {
98    yapi_ai::cost::calculate_cost(model, &mut usage);
99    usage.cost.total
100}
101
102/// Whether warming happens while the run that sent the request is active, or
103/// between runs.
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub enum CacheWarmingPhase {
106    /// The agent run that sent the request is still active.
107    Streaming,
108    /// The agent has settled.
109    Idle,
110}
111
112/// Inputs and outcome of one warm-or-stop decision, as `/session` shows
113/// them.
114#[derive(Clone, Copy, Debug, PartialEq)]
115pub struct CacheWarmingDecision {
116    /// When the decision is made.
117    pub phase: CacheWarmingPhase,
118    /// Price of the refresh: a cache read of the prompt and one output token.
119    pub warm_cost: f64,
120    /// Extra price of the next real request if the cache entry is lost.
121    pub miss_cost: f64,
122    /// Estimated chance that a real request arrives before the entry expires.
123    pub continuation_probability: f64,
124    /// `continuation_probability * miss_cost - warm_cost`.
125    pub expected_savings: f64,
126    /// False when the prompt size or the model's prices are unknown.
127    pub economics_available: bool,
128    /// pi's decision: warm when the expected savings are at least $0.05.
129    pub warm: bool,
130}
131
132impl CacheWarmingDecision {
133    fn action(&self) -> &'static str {
134        if self.warm { "warm" } else { "stop" }
135    }
136}
137
138/// What cache warming is doing.
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum CacheWarmingState {
141    /// Nothing is scheduled.
142    Inactive,
143    /// A refresh is due at `next_warm_at`.
144    Scheduled,
145    /// A refresh is being decided or is in flight.
146    Refreshing,
147}
148
149/// Cache warming's state and the policy inputs that produced it.
150#[derive(Clone, Debug, PartialEq)]
151pub struct CacheWarmingStatus {
152    /// What warming is doing.
153    pub state: CacheWarmingState,
154    /// Why nothing is scheduled.
155    pub reason: Option<&'static str>,
156    /// When the next refresh is decided.
157    pub next_warm_at: Option<SystemTime>,
158    /// The pending decision, or the one that stopped warming.
159    pub decision: Option<CacheWarmingDecision>,
160    /// An extension changed the decision's action.
161    pub extension_override: bool,
162}
163
164impl CacheWarmingStatus {
165    fn inactive(reason: &'static str) -> CacheWarmingStatus {
166        CacheWarmingStatus {
167            state: CacheWarmingState::Inactive,
168            reason: Some(reason),
169            next_warm_at: None,
170            decision: None,
171            extension_override: false,
172        }
173    }
174}
175
176/// The request whose cache entry is kept warm, and the schedule.
177struct Run {
178    /// Tells this run from later ones.
179    id: u64,
180    /// The request as sent, without its credentials, which each refresh
181    /// resolves again.
182    request: Request,
183    /// The session's leaf when the request was sent: its last context entry.
184    leaf: Option<String>,
185    ttl_ms: f64,
186    delay: Duration,
187    /// Latest safe time to send this refresh, leaving half the original
188    /// expiry margin.
189    refresh_deadline: SystemTime,
190    started: SystemTime,
191    /// Cancels the timer and an in-flight refresh.
192    cancel: CancellationToken,
193    phase: CacheWarmingPhase,
194    next_warm_at: SystemTime,
195    /// Set while a refresh that an extension forced is in flight.
196    extension_override: bool,
197    /// The timer has fired: a refresh is being decided or sent.
198    refreshing: bool,
199}
200
201/// A session's cache warming: at most one run, and why the last one ended.
202pub(super) struct CacheWarmer {
203    run: Option<Run>,
204    inactive: CacheWarmingStatus,
205    runs: u64,
206}
207
208impl Default for CacheWarmer {
209    fn default() -> CacheWarmer {
210        CacheWarmer {
211            run: None,
212            inactive: CacheWarmingStatus::inactive("waiting for first request"),
213            runs: 0,
214        }
215    }
216}
217
218impl CacheWarmer {
219    fn clear_run(&mut self) {
220        if let Some(run) = self.run.take() {
221            run.cancel.cancel();
222        }
223    }
224
225    fn stop(&mut self, reason: &'static str, stopped: Option<(CacheWarmingDecision, bool)>) {
226        self.clear_run();
227        self.inactive = CacheWarmingStatus {
228            decision: stopped.map(|(decision, _)| decision),
229            extension_override: stopped.is_some_and(|(_, overridden)| overridden),
230            ..CacheWarmingStatus::inactive(reason)
231        };
232    }
233
234    /// The current run when it is run `id`.
235    fn run(&mut self, id: u64) -> Option<&mut Run> {
236        self.run.as_mut().filter(|run| run.id == id)
237    }
238}
239
240fn mode_stop_reason(mode: CacheWarming, run: &Run) -> Option<&'static str> {
241    match (mode, run.phase) {
242        (CacheWarming::Off, _) => Some("cache warming disabled"),
243        (CacheWarming::Streaming, CacheWarmingPhase::Idle) => Some("agent run settled"),
244        _ => None,
245    }
246}
247
248impl AgentSession {
249    /// The cache warming mode, from the global settings only.
250    pub fn cache_warming_mode(&self) -> CacheWarming {
251        lock(&self.inner.settings).cache_warming_mode()
252    }
253
254    /// pi's `setCacheWarmingMode`: saves the mode in the global settings and
255    /// applies it to the current run at once.
256    pub fn set_cache_warming_mode(&self, mode: CacheWarming) -> Result<(), String> {
257        self.set_global_setting("cacheWarming", serde_json::to_value(mode).ok())?;
258        let mut warmer = lock(&self.inner.cache_warmer);
259        if let Some(reason) = warmer
260            .run
261            .as_ref()
262            .and_then(|run| mode_stop_reason(mode, run))
263        {
264            warmer.stop(reason, None);
265        }
266        Ok(())
267    }
268
269    /// Cache warming's state and the policy inputs that produced it.
270    pub fn cache_warming_status(&self) -> CacheWarmingStatus {
271        if self.cache_warming_mode() == CacheWarming::Off {
272            return CacheWarmingStatus::inactive("cache warming disabled");
273        }
274        let warmer = lock(&self.inner.cache_warmer);
275        let Some(run) = &warmer.run else {
276            return warmer.inactive.clone();
277        };
278        if !self.cache_context_is_current(run) {
279            return CacheWarmingStatus::inactive("conversation context changed");
280        }
281        let decision = self.evaluate_cache_warming(run);
282        if !decision.economics_available && !run.refreshing {
283            return CacheWarmingStatus::inactive("cache economics unavailable");
284        }
285        CacheWarmingStatus {
286            state: if run.refreshing {
287                CacheWarmingState::Refreshing
288            } else {
289                CacheWarmingState::Scheduled
290            },
291            reason: None,
292            next_warm_at: Some(run.next_warm_at),
293            decision: Some(decision),
294            extension_override: run.extension_override,
295        }
296    }
297
298    /// Keeps the cache entry `request` writes warm, in place of any earlier
299    /// request's. Only session requests reach this: compaction and summaries
300    /// use their own cache entries.
301    pub(super) fn start_cache_warming(&self, request: &Request) {
302        let mode = self.cache_warming_mode();
303        let mut warmer = lock(&self.inner.cache_warmer);
304        warmer.clear_run();
305        if mode == CacheWarming::Off {
306            return warmer.stop("cache warming disabled", None);
307        }
308        if !replayable(request) {
309            return warmer.stop("request cannot be replayed safely", None);
310        }
311        let Some(ttl_ms) = ttl_ms(request) else {
312            let reason = match request.options.cache_retention {
313                Some(CacheRetention::None) => "request disabled prompt caching",
314                _ => "cache lifetime unavailable",
315            };
316            return warmer.stop(reason, None);
317        };
318        let Some(delay) = delay_ms(ttl_ms) else {
319            return warmer.stop("cache lifetime unavailable", None);
320        };
321        let mut request = request.clone();
322        request.options.api_key = None;
323        request.options.headers.clear();
324        request.options.env = None;
325        // Each refresh gets its own, which do not see this run's cancellation.
326        request.options.hooks = RequestHooks::default();
327        warmer.runs += 1;
328        let now = now();
329        warmer.run = Some(Run {
330            id: warmer.runs,
331            request,
332            leaf: self.with_session(|session| session.leaf_id().map(str::to_owned)),
333            ttl_ms,
334            delay: Duration::from_millis(delay),
335            refresh_deadline: now,
336            started: now,
337            cancel: CancellationToken::new(),
338            phase: CacheWarmingPhase::Streaming,
339            next_warm_at: now,
340            extension_override: false,
341            refreshing: false,
342        });
343        self.schedule_cache_refresh(&mut warmer);
344    }
345
346    /// pi's `onAgentSettled`: in streaming mode warming stops, and otherwise
347    /// it continues in the idle phase within its shorter limit.
348    pub(super) fn cache_warming_settled(&self) {
349        let mode = self.cache_warming_mode();
350        let mut warmer = lock(&self.inner.cache_warmer);
351        let Some(run) = warmer.run.as_mut() else {
352            return;
353        };
354        if mode == CacheWarming::Streaming {
355            return warmer.stop("agent run settled", None);
356        }
357        run.phase = CacheWarmingPhase::Idle;
358        let deadline = run.started + MAX_IDLE_WARMING_AGE;
359        if run.next_warm_at > deadline || now() >= deadline {
360            warmer.stop("30-minute idle safety limit reached", None);
361        }
362    }
363
364    /// Stops warming for good, as when the session ends.
365    pub(super) fn cancel_cache_warming(&self) {
366        lock(&self.inner.cache_warmer).stop("inactive", None);
367    }
368
369    /// pi's `cacheContextIsCurrent`: the model is still the request's, and
370    /// the context still extends the request's messages. It does while the
371    /// request's last entry stays on the branch, with no compaction after it
372    /// and no edit of an entry up to it.
373    fn cache_context_is_current(&self, run: &Run) -> bool {
374        let state = lock(&self.inner.state);
375        let model = &run.request.model;
376        if !state
377            .model
378            .as_ref()
379            .is_some_and(|current| current.is(&model.provider, &model.id))
380        {
381            return false;
382        }
383        let path = state.session.branch_path(None);
384        fn id<'a>(entry: &&'a FileEntry) -> Option<&'a str> {
385            entry.meta().map(|meta| meta.id.as_str())
386        }
387        let split = match &run.leaf {
388            None => 0,
389            Some(leaf) => match path.iter().position(|entry| id(entry) == Some(leaf)) {
390                Some(position) => position + 1,
391                None => return false,
392            },
393        };
394        let (sent, later) = path.split_at(split);
395        !later.iter().any(|entry| match entry {
396            FileEntry::Compaction(_) => true,
397            FileEntry::ContextEdit(edit) => sent
398                .iter()
399                .any(|entry| id(entry) == Some(edit.target_id.as_str())),
400            _ => false,
401        })
402    }
403
404    fn evaluate_cache_warming(&self, run: &Run) -> CacheWarmingDecision {
405        let model = &run.request.model;
406        let prompt = self.with_session(|session| last_prompt_tokens(&session.branch_path(None)));
407        let read = Usage {
408            cache_read: prompt,
409            ..Usage::default()
410        };
411        let hit_price = price(model, read.clone());
412        let miss_price = price(
413            model,
414            if model.cost.cache_write > 0.0 {
415                Usage {
416                    cache_write: prompt,
417                    ..Usage::default()
418                }
419            } else {
420                Usage {
421                    input: prompt,
422                    ..Usage::default()
423                }
424            },
425        );
426        let warm_cost = price(model, Usage { output: 1, ..read });
427        let miss_cost = (miss_price - hit_price).max(0.0);
428        let continuation_probability = match run.phase {
429            CacheWarmingPhase::Streaming => 1.0,
430            CacheWarmingPhase::Idle => IDLE_CONTINUATION_PROBABILITY,
431        };
432        let expected_savings = continuation_probability * miss_cost - warm_cost;
433        CacheWarmingDecision {
434            phase: run.phase,
435            warm_cost,
436            miss_cost,
437            continuation_probability,
438            expected_savings,
439            economics_available: prompt > 0 && (hit_price > 0.0 || miss_price > 0.0),
440            warm: expected_savings >= MINIMUM_EXPECTED_SAVINGS,
441        }
442    }
443
444    /// Arms the current run's timer, unless its safety limit has passed.
445    fn schedule_cache_refresh(&self, warmer: &mut CacheWarmer) {
446        let Some(run) = warmer.run.as_mut() else {
447            return;
448        };
449        let now = now();
450        run.extension_override = false;
451        run.refreshing = false;
452        run.next_warm_at = now + run.delay;
453        // A timer can fire late after sleep or a blocked runtime. Half of the
454        // planned margin before expiry is kept for that and the request. A
455        // later refresh would likely write the cache at full price.
456        let margin = ((run.ttl_ms - run.delay.as_millis() as f64) / 2.0).floor();
457        run.refresh_deadline = run.next_warm_at + Duration::from_millis(margin as u64);
458        let (limit, reason) = match run.phase {
459            CacheWarmingPhase::Streaming => (MAX_WARMING_AGE, "one-hour safety limit reached"),
460            CacheWarmingPhase::Idle => {
461                (MAX_IDLE_WARMING_AGE, "30-minute idle safety limit reached")
462            }
463        };
464        let deadline = run.started + limit;
465        if run.next_warm_at > deadline || now >= deadline {
466            return warmer.stop(reason, None);
467        }
468        let (id, delay, cancel, session) =
469            (run.id, run.delay, run.cancel.clone(), self.downgrade());
470        tokio::spawn(async move {
471            tokio::select! {
472                () = cancel.cancelled() => {}
473                () = tokio::time::sleep(delay) => {
474                    if let Some(session) = session.upgrade() {
475                        session.refresh_cache(id).await;
476                    }
477                }
478            }
479        });
480    }
481
482    /// pi's `validateRun`: whether run `id` is still current and wanted.
483    /// Otherwise warming stops for the reason it is not.
484    fn cache_run_valid(&self, warmer: &mut CacheWarmer, id: u64) -> bool {
485        let mode = self.cache_warming_mode();
486        let Some(run) = warmer.run(id) else {
487            return false;
488        };
489        let reason = mode_stop_reason(mode, run).or_else(|| {
490            (!self.cache_context_is_current(run)).then_some("conversation context changed")
491        });
492        match reason {
493            Some(reason) => {
494                warmer.stop(reason, None);
495                false
496            }
497            None => true,
498        }
499    }
500
501    /// pi's `refreshDeadlineMissed`.
502    fn cache_refresh_late(warmer: &mut CacheWarmer, id: u64) -> bool {
503        let late = warmer
504            .run(id)
505            .is_some_and(|run| now() > run.refresh_deadline);
506        if late {
507            warmer.stop("cache refresh deadline missed", None);
508        }
509        late
510    }
511
512    /// Decides and sends one refresh of run `id`, then arms the next.
513    async fn refresh_cache(&self, id: u64) {
514        let decision = {
515            let mut warmer = lock(&self.inner.cache_warmer);
516            if let Some(run) = warmer.run(id) {
517                run.refreshing = true;
518            }
519            if !self.cache_run_valid(&mut warmer, id) || Self::cache_refresh_late(&mut warmer, id) {
520                return;
521            }
522            let Some(run) = warmer.run(id) else {
523                return;
524            };
525            self.evaluate_cache_warming(run)
526        };
527        let action = self.cache_warming_decision(&decision).await;
528        let (request, cancel, overridden) = {
529            let mut warmer = lock(&self.inner.cache_warmer);
530            if !self.cache_run_valid(&mut warmer, id) || Self::cache_refresh_late(&mut warmer, id) {
531                return;
532            }
533            let overridden = action != decision.action();
534            if action == "stop" {
535                let reason = if overridden {
536                    "stopped by extension"
537                } else if decision.economics_available {
538                    "expected savings below threshold"
539                } else {
540                    "cache economics unavailable"
541                };
542                return warmer.stop(reason, Some((decision, overridden)));
543            }
544            let Some(run) = warmer.run(id) else {
545                return;
546            };
547            run.extension_override = overridden;
548            (run.request.clone(), run.cancel.clone(), overridden)
549        };
550        let message = self.send_cache_refresh(request, cancel).await;
551        {
552            let mut warmer = lock(&self.inner.cache_warmer);
553            if !self.cache_run_valid(&mut warmer, id) {
554                return;
555            }
556        }
557        if let Some(message) = message.filter(|message| {
558            !matches!(message.stop_reason, StopReason::Error | StopReason::Aborted)
559        }) {
560            let model = message.response_model.as_deref().unwrap_or(&message.model);
561            let note = overridden.then(|| "extension override".to_owned());
562            let entry = self.with_session(|session| {
563                let id = session
564                    .append_usage(
565                        "cache_warm",
566                        &message.provider,
567                        model,
568                        message.usage.clone(),
569                        note,
570                    )
571                    .ok()?;
572                session.entry(&id).cloned()
573            });
574            if let Some(entry) = entry {
575                self.emit(&AgentEvent::EntryAppended { entry });
576            }
577        }
578        let mut warmer = lock(&self.inner.cache_warmer);
579        if warmer.run(id).is_some() {
580            self.schedule_cache_refresh(&mut warmer);
581        }
582    }
583
584    /// pi's `cache_warming_decision` event: each handler sees pi's own
585    /// decision, and the last action a handler returns wins.
586    async fn cache_warming_decision(&self, decision: &CacheWarmingDecision) -> Value {
587        let mut action = Value::from(decision.action());
588        let handlers = self.handlers_of("cache_warming_decision");
589        if handlers.is_empty() {
590            return action;
591        }
592        let event = ExtensionEvent::CacheWarmingDecision {
593            warm_cost: decision.warm_cost,
594            miss_cost: decision.miss_cost,
595            continuation_probability: decision.continuation_probability,
596            action: decision.action(),
597        }
598        .to_value();
599        let ctx = self.extension_context(CancellationToken::new());
600        for extension in handlers {
601            // Without the session grant, a handler may only stop warming.
602            if let Some(next) = extension
603                .handle(&ctx, &event)
604                .await
605                .and_then(|mut result| result.get_mut("action").map(Value::take))
606                && (next == "stop"
607                    || next == action
608                    || self.applies(
609                        extension,
610                        "cache_warming_decision",
611                        extension.grants().session,
612                        super::extensions::SESSION,
613                    ))
614            {
615                action = next;
616            }
617        }
618        action
619    }
620
621    /// Sends `request` again with a one-token output cap and fresh
622    /// credentials, and returns its response unless it could not be sent.
623    async fn send_cache_refresh(
624        &self,
625        mut request: Request,
626        cancel: CancellationToken,
627    ) -> Option<yapi_types::message::AssistantMessage> {
628        request.options.max_tokens = Some(1);
629        request.options.max_retries = 0;
630        request.options.cancel = cancel;
631        // Extensions see the current run's signal, as pi's live `ctx.signal`.
632        let current = lock(&self.inner.cancel).clone().unwrap_or_default();
633        request.options.hooks = self.request_hooks(&request.model, &current);
634        let auth = self.registry().auth(&request.model).await;
635        auth.apply(&mut request).ok()?;
636        yapi_ai::stream::RequestHooks::prepare(&mut request).await;
637        self.apis().stream(request).result().await
638    }
639}
640
641/// pi's dollar amounts: three decimals, the sign before the dollar sign.
642fn dollars(value: f64) -> String {
643    let sign = if value < 0.0 { "-" } else { "" };
644    format!("{sign}${}", js::to_fixed(value.abs(), 3))
645}
646
647fn economics(decision: &CacheWarmingDecision) -> String {
648    if !decision.economics_available {
649        return "cache economics unavailable".into();
650    }
651    let probability = js::round(decision.continuation_probability * 100.0);
652    let while_running = match decision.phase {
653        CacheWarmingPhase::Streaming => " while agent is running",
654        CacheWarmingPhase::Idle => "",
655    };
656    let comparison = if decision.warm { ">=" } else { "<" };
657    format!(
658        "{probability}% continuation probability{while_running}, expected savings {} {comparison} {}",
659        dollars(decision.expected_savings),
660        dollars(MINIMUM_EXPECTED_SAVINGS)
661    )
662}
663
664fn decision_time(next_warm_at: Option<SystemTime>, now: SystemTime) -> String {
665    let Some(remaining) = next_warm_at
666        .and_then(|at| at.duration_since(now).ok())
667        .filter(|remaining| !remaining.is_zero())
668    else {
669        return "Decision now".into();
670    };
671    let seconds = remaining.as_millis().div_ceil(1000);
672    let (hours, minutes, seconds) = (seconds / 3600, seconds % 3600 / 60, seconds % 60);
673    let mut parts = Vec::new();
674    if hours > 0 {
675        parts.push(format!("{hours}h"));
676    }
677    if minutes > 0 {
678        parts.push(format!("{minutes}m"));
679    }
680    if seconds > 0 || parts.is_empty() {
681        parts.push(format!("{seconds}s"));
682    }
683    format!("Decision in {}", parts.join(" "))
684}
685
686/// pi's one-line cache warming status for `/session`.
687pub fn format_cache_warming_status(status: &CacheWarmingStatus, now: SystemTime) -> String {
688    // A decision is attached once pi or an extension acted on it.
689    let decision = match &status.decision {
690        Some(decision)
691            if status.state != CacheWarmingState::Inactive
692                || decision.economics_available
693                || status.extension_override =>
694        {
695            decision
696        }
697        _ => return format!("Inactive ({})", status.reason.unwrap_or("unknown reason")),
698    };
699    let details = if status.extension_override {
700        format!("extension override, {}", economics(decision))
701    } else {
702        format!("{} -> {}", economics(decision), decision.action())
703    };
704    match status.state {
705        CacheWarmingState::Inactive => format!("Stopped ({details})"),
706        CacheWarmingState::Refreshing => format!("Warming cache ({details})"),
707        CacheWarmingState::Scheduled => {
708            format!("{} ({details})", decision_time(status.next_warm_at, now))
709        }
710    }
711}
712
713/// pi's transcript line for a recorded refresh: its cost with three to six
714/// decimals.
715pub fn format_cache_warming_usage(entry: &UsageEntry) -> String {
716    let note = entry
717        .note
718        .as_ref()
719        .map(|note| format!(" ({note})"))
720        .unwrap_or_default();
721    let mut cost = js::to_fixed(entry.usage.cost.total, 6);
722    while cost.ends_with('0') && cost.len() - cost.find('.').unwrap_or(0) > 4 {
723        cost.pop();
724    }
725    format!("Cache warmed{note}: ${cost}")
726}
727
728#[cfg(test)]
729mod tests {
730    #![allow(clippy::unwrap_used, reason = "a panic is a test failure")]
731
732    use std::cell::Cell;
733    use std::collections::VecDeque;
734    use std::path::Path;
735    use std::sync::{Arc, Mutex};
736
737    use futures_util::future::BoxFuture;
738    use yapi_ai::api::Apis;
739    use yapi_ai::registry::ModelRegistry;
740    use yapi_ai::stream::{EventStream, Provider, StreamEvent, StreamOptions};
741    use yapi_types::message::{ContentBlock, ThinkingLevel};
742    use yapi_types::session::UsageEntry;
743
744    use super::*;
745    use crate::agent_session::{Resources, SessionConfig};
746    use crate::extensions::{Context, Extension, Grants, builtin_source};
747    use crate::resources::SourceInfo;
748    use crate::session::SessionManager;
749    use crate::settings::SettingsManager;
750
751    thread_local! {
752        static START: Cell<Option<tokio::time::Instant>> = const { Cell::new(None) };
753        static JUMP: Cell<Duration> = const { Cell::new(Duration::ZERO) };
754    }
755
756    /// The wall clock in tests: the paused runtime's time, plus jumps that
757    /// model a machine that slept while its timers stood still.
758    pub(super) fn now() -> SystemTime {
759        let start = START.with(|start| {
760            let value = start.get().unwrap_or_else(tokio::time::Instant::now);
761            start.set(Some(value));
762            value
763        });
764        SystemTime::UNIX_EPOCH + start.elapsed() + JUMP.with(Cell::get)
765    }
766
767    fn sleep_through(duration: Duration) {
768        JUMP.with(|jump| jump.set(jump.get() + duration));
769    }
770
771    /// A model with a five-minute prompt cache whose writes cost far more
772    /// than reads, so warming pays off even between runs.
773    fn cached_model() -> Model {
774        serde_json::from_value(serde_json::json!({
775            "id": "cached", "name": "Cached", "api": "faux", "provider": "faux", "baseUrl": "",
776            "reasoning": false, "input": ["text"],
777            "cost": {"input": 3, "output": 15, "cacheRead": 1, "cacheWrite": 100},
778            "promptCache": {"short": 300},
779            "contextWindow": 100000, "maxTokens": 8000,
780        }))
781        .unwrap()
782    }
783
784    /// A request's output cap and cancel token.
785    type Sent = (Option<u64>, CancellationToken);
786
787    /// Answers each request with text and a 20,000-token prompt written to
788    /// the cache, after the next of `delays` for requests without an output
789    /// cap. Refreshes end with `refresh_stop`, or, while `hold` is set, only
790    /// when cancelled. Records each request's output cap and cancel token.
791    #[derive(Clone)]
792    struct Cached {
793        requests: Arc<Mutex<Vec<Sent>>>,
794        delays: Arc<Mutex<VecDeque<Duration>>>,
795        refresh_stop: StopReason,
796        hold: bool,
797    }
798
799    impl Default for Cached {
800        fn default() -> Cached {
801            Cached {
802                requests: Arc::default(),
803                delays: Arc::default(),
804                refresh_stop: StopReason::Length,
805                hold: false,
806            }
807        }
808    }
809
810    impl Cached {
811        fn caps(&self) -> Vec<Option<u64>> {
812            let requests = self.requests.lock().unwrap();
813            requests.iter().map(|(cap, _)| *cap).collect()
814        }
815    }
816
817    impl Provider for Cached {
818        fn api(&self) -> &str {
819            "faux"
820        }
821
822        fn stream(&self, request: Request) -> EventStream {
823            let cap = request.options.max_tokens;
824            let cancel = request.options.cancel.clone();
825            self.requests.lock().unwrap().push((cap, cancel.clone()));
826            let delay = match cap {
827                Some(_) => Duration::ZERO,
828                None => self.delays.lock().unwrap().pop_front().unwrap_or_default(),
829            };
830            let (sender, stream) = EventStream::channel();
831            let mut output = yapi_ai::stream::new_output(&request.model, 0);
832            output.content = vec![ContentBlock::text("ok")];
833            output.usage.cache_write = 20_000;
834            output.stop_reason = match cap {
835                Some(_) => self.refresh_stop,
836                None => StopReason::Stop,
837            };
838            let hold = cap.is_some() && self.hold;
839            tokio::spawn(async move {
840                if hold {
841                    cancel.cancelled().await;
842                    output.stop_reason = StopReason::Aborted;
843                }
844                tokio::time::sleep(delay).await;
845                sender.send(match output.stop_reason {
846                    StopReason::Error | StopReason::Aborted => StreamEvent::Error(output),
847                    _ => StreamEvent::Done(output),
848                });
849            });
850            stream
851        }
852    }
853
854    /// Answers `cache_warming_decision` with `decide`'s action, if any, with
855    /// these grants.
856    struct Decide(Box<dyn Fn() -> Option<&'static str> + Send + Sync>, Grants);
857
858    impl Extension for Decide {
859        fn source(&self) -> SourceInfo {
860            builtin_source("decide")
861        }
862
863        fn grants(&self) -> Grants {
864            self.1
865        }
866
867        fn handles(&self, kind: &str) -> bool {
868            kind == "cache_warming_decision"
869        }
870
871        fn handle<'a>(
872            &'a self,
873            _ctx: &'a Context,
874            event: &'a Value,
875        ) -> BoxFuture<'a, Option<Value>> {
876            assert_eq!(event["type"], "cache_warming_decision");
877            let action = (self.0)();
878            Box::pin(async move { action.map(|action| serde_json::json!({"action": action})) })
879        }
880    }
881
882    fn warming_session(
883        model: Model,
884        provider: &Cached,
885        extensions: Vec<Arc<dyn Extension>>,
886        mode: CacheWarming,
887    ) -> AgentSession {
888        let mut registry = ModelRegistry::builtin();
889        registry.register_provider("faux", vec![model.clone()]);
890        registry.set_runtime_key("faux", "key".into());
891        let mut apis = Apis::default();
892        apis.register(Arc::new(provider.clone()));
893        let session = AgentSession::new(SessionConfig {
894            cwd: Path::new("/work").to_path_buf(),
895            agent_dir: Path::new("/agent").to_path_buf(),
896            settings: SettingsManager::in_memory(),
897            registry,
898            apis,
899            session: SessionManager::in_memory(Path::new("/work")),
900            model: Some(model),
901            thinking_level: ThinkingLevel::Off,
902            tools: Vec::new(),
903            extensions,
904            include_extension_tools: false,
905            allowed_tools: None,
906            excluded_tools: Vec::new(),
907            resources: Resources::default(),
908            docs: crate::docs::Locations::default(),
909        });
910        session.set_cache_warming_mode(mode).unwrap();
911        session
912    }
913
914    fn warm_entries(session: &AgentSession) -> Vec<UsageEntry> {
915        session.with_session(|file| {
916            file.entries()
917                .filter_map(|entry| match entry {
918                    FileEntry::Usage(usage) => Some(usage.clone()),
919                    _ => None,
920                })
921                .collect()
922        })
923    }
924
925    /// A refresh of the five-minute cache is due 270s after the request.
926    const REFRESH: Duration = Duration::from_secs(271);
927
928    #[tokio::test(start_paused = true)]
929    async fn idle_warming_until_limit() {
930        let provider = Cached::default();
931        let session = warming_session(cached_model(), &provider, Vec::new(), CacheWarming::Idle);
932        let appended = Arc::new(Mutex::new(0));
933        let counter = Arc::clone(&appended);
934        session.subscribe(Box::new(move |event| {
935            if let AgentEvent::EntryAppended {
936                entry: FileEntry::Usage(_),
937            } = event
938            {
939                *counter.lock().unwrap() += 1;
940            }
941        }));
942        session.prompt("hi", Vec::new()).await.unwrap();
943
944        let status = session.cache_warming_status();
945        assert_eq!(status.state, CacheWarmingState::Scheduled);
946        let decision = status.decision.unwrap();
947        assert_eq!(decision.phase, CacheWarmingPhase::Idle);
948        assert_eq!(decision.continuation_probability, 0.15);
949        assert!(decision.warm);
950
951        tokio::time::sleep(REFRESH).await;
952        assert_eq!(provider.caps(), [None, Some(1)]);
953        let entries = warm_entries(&session);
954        assert_eq!(entries.len(), 1);
955        assert_eq!(
956            (entries[0].kind.as_str(), entries[0].provider.as_str()),
957            ("cache_warm", "faux")
958        );
959        assert_eq!(entries[0].model, "cached");
960        assert_eq!(entries[0].note, None);
961        assert_eq!(*appended.lock().unwrap(), 1);
962        assert_eq!(
963            session.cache_warming_status().state,
964            CacheWarmingState::Scheduled
965        );
966
967        // The sixth refresh, at 27 minutes, is the last within the 30-minute
968        // idle limit.
969        tokio::time::sleep(Duration::from_secs(30 * 60)).await;
970        assert_eq!(provider.caps().len(), 7);
971        assert_eq!(warm_entries(&session).len(), 6);
972        let status = session.cache_warming_status();
973        assert_eq!(status.state, CacheWarmingState::Inactive);
974        assert_eq!(status.reason, Some("30-minute idle safety limit reached"));
975    }
976
977    #[tokio::test(start_paused = true)]
978    async fn streaming_warming_refreshes() {
979        let provider = Cached::default();
980        provider
981            .delays
982            .lock()
983            .unwrap()
984            .extend([Duration::ZERO, Duration::from_secs(400)]);
985        let session = warming_session(
986            cached_model(),
987            &provider,
988            Vec::new(),
989            CacheWarming::Streaming,
990        );
991        session.prompt("first", Vec::new()).await.unwrap();
992        assert_eq!(
993            session.cache_warming_status().reason,
994            Some("agent run settled")
995        );
996
997        // The second response takes 400s. Its request's cache entry is
998        // refreshed at 270s, priced from the first response's prompt.
999        let run = tokio::spawn({
1000            let session = session.clone();
1001            async move { session.prompt("second", Vec::new()).await }
1002        });
1003        tokio::time::sleep(REFRESH).await;
1004        assert_eq!(provider.caps(), [None, None, Some(1)]);
1005        let status = session.cache_warming_status();
1006        assert_eq!(status.state, CacheWarmingState::Scheduled);
1007        assert_eq!(status.decision.unwrap().phase, CacheWarmingPhase::Streaming);
1008
1009        run.await.unwrap().unwrap();
1010        assert_eq!(
1011            session.cache_warming_status().reason,
1012            Some("agent run settled")
1013        );
1014        tokio::time::sleep(Duration::from_secs(600)).await;
1015        assert_eq!(provider.caps().len(), 3);
1016    }
1017
1018    #[tokio::test(start_paused = true)]
1019    async fn warming_stops_on_change() {
1020        let provider = Cached::default();
1021        let session = warming_session(cached_model(), &provider, Vec::new(), CacheWarming::Idle);
1022        session.prompt("hi", Vec::new()).await.unwrap();
1023        session
1024            .set_model(Model {
1025                id: "other".into(),
1026                ..cached_model()
1027            })
1028            .unwrap();
1029        assert_eq!(
1030            session.cache_warming_status().reason,
1031            Some("conversation context changed")
1032        );
1033        tokio::time::sleep(REFRESH).await;
1034        assert_eq!(provider.caps(), [None]);
1035
1036        session.set_model(cached_model()).unwrap();
1037        session.prompt("again", Vec::new()).await.unwrap();
1038        // An edit of a message the request sent changes its prefix.
1039        session.with_session(|file| {
1040            let user = file
1041                .branch_path(None)
1042                .iter()
1043                .rev()
1044                .find_map(|entry| match entry {
1045                    FileEntry::Message(entry) if matches!(entry.message, Message::User(_)) => {
1046                        Some(entry.meta.id.clone())
1047                    }
1048                    _ => None,
1049                })
1050                .unwrap();
1051            file.append_context_edit(&user, None).unwrap();
1052        });
1053        assert_eq!(
1054            session.cache_warming_status().reason,
1055            Some("conversation context changed")
1056        );
1057
1058        session.prompt("once more", Vec::new()).await.unwrap();
1059        assert_eq!(
1060            session.cache_warming_status().state,
1061            CacheWarmingState::Scheduled
1062        );
1063        session.set_cache_warming_mode(CacheWarming::Off).unwrap();
1064        assert_eq!(
1065            session.cache_warming_status().reason,
1066            Some("cache warming disabled")
1067        );
1068        tokio::time::sleep(REFRESH).await;
1069        assert_eq!(provider.caps(), [None, None, None]);
1070    }
1071
1072    #[tokio::test(start_paused = true)]
1073    async fn warming_needs_lifetime_economics() {
1074        let mut model = cached_model();
1075        model.prompt_cache = None;
1076        let session = warming_session(model, &Cached::default(), Vec::new(), CacheWarming::Idle);
1077        session.prompt("hi", Vec::new()).await.unwrap();
1078        assert_eq!(
1079            session.cache_warming_status().reason,
1080            Some("cache lifetime unavailable")
1081        );
1082
1083        // Free cache reads and writes: nothing to save.
1084        let mut model = cached_model();
1085        model.cost = Default::default();
1086        let session = warming_session(model, &Cached::default(), Vec::new(), CacheWarming::Idle);
1087        session.prompt("hi", Vec::new()).await.unwrap();
1088        assert_eq!(
1089            session.cache_warming_status().reason,
1090            Some("cache economics unavailable")
1091        );
1092    }
1093
1094    #[tokio::test(start_paused = true)]
1095    async fn late_refresh_not_sent() {
1096        let provider = Cached::default();
1097        let session = warming_session(cached_model(), &provider, Vec::new(), CacheWarming::Idle);
1098        session.prompt("hi", Vec::new()).await.unwrap();
1099        // The refresh due at 270s may go out until 285s. The machine
1100        // sleeps for 16s while the timer waits.
1101        sleep_through(Duration::from_secs(16));
1102        tokio::time::sleep(REFRESH).await;
1103        assert_eq!(provider.caps(), [None]);
1104        assert_eq!(
1105            session.cache_warming_status().reason,
1106            Some("cache refresh deadline missed")
1107        );
1108    }
1109
1110    #[tokio::test(start_paused = true)]
1111    async fn deadline_rechecked_after_extensions() {
1112        let provider = Cached::default();
1113        let slow = Decide(
1114            Box::new(|| {
1115                sleep_through(Duration::from_secs(16));
1116                Some("warm")
1117            }),
1118            Grants::default(),
1119        );
1120        let session = warming_session(
1121            cached_model(),
1122            &provider,
1123            vec![Arc::new(slow)],
1124            CacheWarming::Idle,
1125        );
1126        session.prompt("hi", Vec::new()).await.unwrap();
1127        tokio::time::sleep(REFRESH).await;
1128        assert_eq!(provider.caps(), [None]);
1129        assert_eq!(
1130            session.cache_warming_status().reason,
1131            Some("cache refresh deadline missed")
1132        );
1133    }
1134
1135    #[tokio::test(start_paused = true)]
1136    async fn extensions_override_decision() {
1137        // pi would warm. An extension stops it, which needs no grant.
1138        let no_session = Grants {
1139            session: false,
1140            ..Grants::default()
1141        };
1142        let provider = Cached::default();
1143        let veto = Decide(Box::new(|| Some("stop")), no_session);
1144        let session = warming_session(
1145            cached_model(),
1146            &provider,
1147            vec![Arc::new(veto)],
1148            CacheWarming::Idle,
1149        );
1150        session.prompt("hi", Vec::new()).await.unwrap();
1151        tokio::time::sleep(REFRESH).await;
1152        assert_eq!(provider.caps(), [None]);
1153        let status = session.cache_warming_status();
1154        assert_eq!(status.reason, Some("stopped by extension"));
1155        assert!(status.extension_override);
1156        assert!(
1157            format_cache_warming_status(&status, now())
1158                .starts_with("Stopped (extension override, 15% continuation probability")
1159        );
1160
1161        // pi would stop between runs at Anthropic's prices. An extension
1162        // warms anyway, and the refresh is noted as its override.
1163        let mut model = cached_model();
1164        model.cost.cache_read = 0.3;
1165        model.cost.cache_write = 3.75;
1166        let provider = Cached::default();
1167        let force = Decide(Box::new(|| Some("warm")), Grants::default());
1168        let session = warming_session(
1169            model.clone(),
1170            &provider,
1171            vec![Arc::new(force)],
1172            CacheWarming::Idle,
1173        );
1174        session.prompt("hi", Vec::new()).await.unwrap();
1175        assert!(!session.cache_warming_status().decision.unwrap().warm);
1176        tokio::time::sleep(REFRESH).await;
1177        assert_eq!(provider.caps(), [None, Some(1)]);
1178        assert_eq!(
1179            warm_entries(&session)[0].note.as_deref(),
1180            Some("extension override")
1181        );
1182
1183        // Warming anyway sends a request, which needs the session grant.
1184        let provider = Cached::default();
1185        let force = Decide(Box::new(|| Some("warm")), no_session);
1186        let session = warming_session(model, &provider, vec![Arc::new(force)], CacheWarming::Idle);
1187        session.prompt("hi", Vec::new()).await.unwrap();
1188        tokio::time::sleep(REFRESH).await;
1189        assert_eq!(provider.caps(), [None]);
1190
1191        // A handler without an action leaves pi's decision.
1192        let provider = Cached::default();
1193        let silent = Decide(Box::new(|| None), Grants::default());
1194        let session = warming_session(
1195            cached_model(),
1196            &provider,
1197            vec![Arc::new(silent)],
1198            CacheWarming::Idle,
1199        );
1200        session.prompt("hi", Vec::new()).await.unwrap();
1201        tokio::time::sleep(REFRESH).await;
1202        assert_eq!(provider.caps(), [None, Some(1)]);
1203        assert_eq!(warm_entries(&session)[0].note, None);
1204    }
1205
1206    #[tokio::test(start_paused = true)]
1207    async fn failed_and_replaced_refreshes() {
1208        let provider = Cached {
1209            refresh_stop: StopReason::Error,
1210            ..Cached::default()
1211        };
1212        let session = warming_session(cached_model(), &provider, Vec::new(), CacheWarming::Idle);
1213        session.prompt("hi", Vec::new()).await.unwrap();
1214        tokio::time::sleep(REFRESH).await;
1215        assert_eq!(provider.caps(), [None, Some(1)]);
1216        assert!(warm_entries(&session).is_empty());
1217        assert_eq!(
1218            session.cache_warming_status().state,
1219            CacheWarmingState::Scheduled
1220        );
1221
1222        let provider = Cached {
1223            hold: true,
1224            ..Cached::default()
1225        };
1226        let session = warming_session(cached_model(), &provider, Vec::new(), CacheWarming::Idle);
1227        session.prompt("hi", Vec::new()).await.unwrap();
1228        tokio::time::sleep(REFRESH).await;
1229        assert_eq!(
1230            session.cache_warming_status().state,
1231            CacheWarmingState::Refreshing
1232        );
1233        // A new request replaces the run and aborts its refresh.
1234        session.prompt("again", Vec::new()).await.unwrap();
1235        let requests = provider.requests.lock().unwrap().clone();
1236        assert_eq!(requests.len(), 3);
1237        assert!(requests[1].1.is_cancelled());
1238        tokio::task::yield_now().await;
1239        assert!(warm_entries(&session).is_empty());
1240    }
1241
1242    #[test]
1243    fn budget_thinking_not_replayed() {
1244        let request = |api: &str, compat: Value, reasoning| Request {
1245            model: Model {
1246                api: api.into(),
1247                compat: compat.as_object().cloned(),
1248                ..cached_model()
1249            },
1250            messages: Vec::new(),
1251            options: StreamOptions {
1252                reasoning,
1253                ..StreamOptions::default()
1254            },
1255        };
1256        let none = Value::Null;
1257        let adaptive = serde_json::json!({"forceAdaptiveThinking": true});
1258        let medium = Some(ThinkingLevel::Medium);
1259        assert!(!replayable(&request(
1260            "anthropic-messages",
1261            none.clone(),
1262            medium
1263        )));
1264        assert!(replayable(&request(
1265            "anthropic-messages",
1266            none.clone(),
1267            None
1268        )));
1269        assert!(replayable(&request("anthropic-messages", adaptive, medium)));
1270        assert!(replayable(&request("openai-responses", none, medium)));
1271    }
1272
1273    /// The lifetime follows `PI_CACHE_RETENTION` in the provider env too.
1274    #[test]
1275    fn env_selects_cache_lifetime() {
1276        let request = Request {
1277            model: Model {
1278                prompt_cache: Some(yapi_types::models::PromptCache {
1279                    short: Some(300.0),
1280                    long: Some(3600.0),
1281                }),
1282                ..cached_model()
1283            },
1284            messages: Vec::new(),
1285            options: StreamOptions {
1286                env: Some([("PI_CACHE_RETENTION".to_owned(), "long".to_owned())].into()),
1287                ..StreamOptions::default()
1288            },
1289        };
1290        assert_eq!(ttl_ms(&request), Some(3_600_000.0));
1291    }
1292
1293    #[test]
1294    fn refreshes_at_ninety_percent() {
1295        assert_eq!(delay_ms(10_000.0), None);
1296        assert_eq!(delay_ms(11_000.0), Some(1_000));
1297        assert_eq!(delay_ms(300_000.0), Some(270_000));
1298        assert_eq!(delay_ms(3_600_000.0), Some(3_240_000));
1299    }
1300
1301    #[test]
1302    fn formats_status() {
1303        let decision = CacheWarmingDecision {
1304            phase: CacheWarmingPhase::Streaming,
1305            warm_cost: 0.006,
1306            miss_cost: 0.069,
1307            continuation_probability: 1.0,
1308            expected_savings: 0.063,
1309            economics_available: true,
1310            warm: true,
1311        };
1312        let now = SystemTime::UNIX_EPOCH;
1313        let scheduled = CacheWarmingStatus {
1314            state: CacheWarmingState::Scheduled,
1315            reason: None,
1316            next_warm_at: Some(now + Duration::from_millis(3_661_500)),
1317            decision: Some(decision),
1318            extension_override: false,
1319        };
1320        assert_eq!(
1321            format_cache_warming_status(&scheduled, now),
1322            "Decision in 1h 1m 2s (100% continuation probability while agent is running, expected savings $0.063 >= $0.050 -> warm)"
1323        );
1324        let stopped = CacheWarmingStatus {
1325            state: CacheWarmingState::Inactive,
1326            reason: Some("expected savings below threshold"),
1327            next_warm_at: None,
1328            decision: Some(CacheWarmingDecision {
1329                phase: CacheWarmingPhase::Idle,
1330                continuation_probability: IDLE_CONTINUATION_PROBABILITY,
1331                expected_savings: -0.002,
1332                warm: false,
1333                ..decision
1334            }),
1335            extension_override: false,
1336        };
1337        assert_eq!(
1338            format_cache_warming_status(&stopped, now),
1339            "Stopped (15% continuation probability, expected savings -$0.002 < $0.050 -> stop)"
1340        );
1341        let forced = CacheWarmingStatus {
1342            state: CacheWarmingState::Refreshing,
1343            extension_override: true,
1344            ..stopped
1345        };
1346        assert_eq!(
1347            format_cache_warming_status(&forced, now),
1348            "Warming cache (extension override, 15% continuation probability, expected savings -$0.002 < $0.050)"
1349        );
1350        assert_eq!(
1351            format_cache_warming_status(&CacheWarmingStatus::inactive("agent run settled"), now),
1352            "Inactive (agent run settled)"
1353        );
1354    }
1355
1356    #[test]
1357    fn refresh_cost_decimals() {
1358        let mut entry: UsageEntry = serde_json::from_value(serde_json::json!({
1359            "id": "u", "parentId": null, "timestamp": "2026-10-13T10:00:00.000Z",
1360            "kind": "cache_warm", "provider": "p", "model": "m",
1361            "usage": {"input": 0, "output": 1, "cacheRead": 0, "cacheWrite": 0,
1362                "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0.006051}},
1363        }))
1364        .expect("a usage entry");
1365        assert_eq!(
1366            format_cache_warming_usage(&entry),
1367            "Cache warmed: $0.006051"
1368        );
1369        entry.usage.cost.total = 0.01;
1370        entry.note = Some("extension override".into());
1371        assert_eq!(
1372            format_cache_warming_usage(&entry),
1373            "Cache warmed (extension override): $0.010"
1374        );
1375    }
1376
1377    /// Never finishes its `agent_settled` handler.
1378    struct HangSettled;
1379
1380    impl Extension for HangSettled {
1381        fn source(&self) -> SourceInfo {
1382            builtin_source("hang")
1383        }
1384
1385        fn handles(&self, kind: &str) -> bool {
1386            kind == "agent_settled"
1387        }
1388
1389        fn handle<'a>(
1390            &'a self,
1391            _ctx: &'a Context,
1392            _event: &'a Value,
1393        ) -> BoxFuture<'a, Option<Value>> {
1394            Box::pin(std::future::pending())
1395        }
1396    }
1397
1398    #[tokio::test(start_paused = true)]
1399    async fn dropped_run_clears_settling() {
1400        let provider = Cached::default();
1401        let ext: Arc<dyn Extension> = Arc::new(HangSettled);
1402        let session = warming_session(cached_model(), &provider, vec![ext], CacheWarming::Off);
1403        let run = tokio::time::timeout(Duration::from_secs(5), session.prompt("hi", Vec::new()));
1404        assert!(run.await.is_err());
1405        assert!(session.settled_turn().is_none(), "settling stayed true");
1406    }
1407
1408    /// A follow-up queued after the run last looked at its queues, as it
1409    /// would end, runs in that run, as one queued earlier does.
1410    #[tokio::test]
1411    async fn late_follow_up_runs() {
1412        let provider = Cached::default();
1413        let session = warming_session(cached_model(), &provider, Vec::new(), CacheWarming::Off);
1414        let late = session.clone();
1415        *lock(&session.inner.races.before_end) = Some(Box::new(move || {
1416            let message = yapi_types::message::CustomMessage {
1417                custom_type: "late".into(),
1418                content: yapi_types::message::Content::Text("late".into()),
1419                display: true,
1420                details: None,
1421                timestamp: 1,
1422            };
1423            let run = late.deliver_custom_message(message, Some(true), Some("followUp"));
1424            assert!(run.is_none(), "the run had ended");
1425        }));
1426        let run = tokio::time::timeout(Duration::from_secs(5), session.prompt("hi", Vec::new()));
1427        run.await.unwrap().unwrap();
1428        // The prompt's request, then the follow-up's.
1429        assert_eq!(provider.caps().len(), 2);
1430        assert!(!session.has_queued());
1431    }
1432
1433    /// Holds its `agent_settled` handler until `release` is notified, and
1434    /// tells `entered` as it starts.
1435    struct GateSettled {
1436        entered: tokio::sync::mpsc::UnboundedSender<()>,
1437        release: Arc<tokio::sync::Notify>,
1438    }
1439
1440    impl Extension for GateSettled {
1441        fn source(&self) -> SourceInfo {
1442            builtin_source("gate")
1443        }
1444
1445        fn handles(&self, kind: &str) -> bool {
1446            kind == "agent_settled"
1447        }
1448
1449        fn handle<'a>(
1450            &'a self,
1451            _ctx: &'a Context,
1452            _event: &'a Value,
1453        ) -> BoxFuture<'a, Option<Value>> {
1454            let _ = self.entered.send(());
1455            let release = self.release.clone();
1456            Box::pin(async move {
1457                release.notified().await;
1458                None
1459            })
1460        }
1461    }
1462
1463    /// A prompt that asks to wait for the `agent_settled` handlers as they
1464    /// finish either takes its place before the run starts the deferred
1465    /// prompts, or starts at once: it never waits for a run that ended.
1466    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1467    async fn late_deferral_starts() {
1468        let provider = Cached::default();
1469        let (entered, mut handlers) = tokio::sync::mpsc::unbounded_channel();
1470        let release = Arc::new(tokio::sync::Notify::new());
1471        let ext: Arc<dyn Extension> = Arc::new(GateSettled {
1472            entered,
1473            release: release.clone(),
1474        });
1475        let session = warming_session(cached_model(), &provider, vec![ext], CacheWarming::Off);
1476        let run = tokio::spawn({
1477            let session = session.clone();
1478            async move { session.prompt("hi", Vec::new()).await }
1479        });
1480        handlers.recv().await.unwrap();
1481        // The handlers finish and the run ends meanwhile.
1482        let (ended, run_ended) = std::sync::mpsc::channel();
1483        *lock(&session.inner.races.before_defer) = Some(Box::new(move || {
1484            release.notify_one();
1485            run_ended.recv().unwrap();
1486        }));
1487        let asking = session.clone();
1488        // Whether the deferred prompt started, if it was deferred.
1489        let started = tokio::task::spawn_blocking(move || {
1490            let turn = asking.settled_turn()?;
1491            let wait = tokio::time::timeout(Duration::from_secs(5), turn);
1492            Some(tokio::runtime::Handle::current().block_on(wait).is_ok())
1493        });
1494        run.await.unwrap().unwrap();
1495        ended.send(()).unwrap();
1496        let started = started.await.unwrap();
1497        assert_ne!(started, Some(false), "the deferred prompt never started");
1498    }
1499
1500    /// Records whether the context of each `before_provider_headers` is cancelled.
1501    struct SeesCancel(Arc<Mutex<Vec<bool>>>);
1502
1503    impl Extension for SeesCancel {
1504        fn source(&self) -> SourceInfo {
1505            builtin_source("sees-cancel")
1506        }
1507
1508        fn handles(&self, kind: &str) -> bool {
1509            kind == "before_provider_headers"
1510        }
1511
1512        fn handle<'a>(
1513            &'a self,
1514            ctx: &'a Context,
1515            _event: &'a Value,
1516        ) -> BoxFuture<'a, Option<Value>> {
1517            self.0.lock().unwrap().push(ctx.cancel.is_cancelled());
1518            Box::pin(async { None })
1519        }
1520    }
1521
1522    #[tokio::test(start_paused = true)]
1523    async fn hooks_skip_aborted_run() {
1524        let provider = Cached::default();
1525        provider
1526            .delays
1527            .lock()
1528            .unwrap()
1529            .push_back(Duration::from_secs(10));
1530        let seen = Arc::new(Mutex::new(Vec::new()));
1531        let ext: Arc<dyn Extension> = Arc::new(SeesCancel(seen.clone()));
1532        let session = warming_session(cached_model(), &provider, vec![ext], CacheWarming::Idle);
1533        let run = tokio::spawn({
1534            let session = session.clone();
1535            async move { session.prompt("hi", Vec::new()).await }
1536        });
1537        tokio::time::sleep(Duration::from_secs(1)).await;
1538        session.abort();
1539        run.await.unwrap().unwrap();
1540        tokio::time::sleep(REFRESH * 2).await;
1541        // The first entry is the run's own request, before the abort.
1542        let seen = seen.lock().unwrap();
1543        assert!(
1544            seen.len() > 1 && !seen[1..].contains(&true),
1545            "refresh hooks saw the aborted run: {seen:?}"
1546        );
1547    }
1548}