1use 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
23const MAX_WARMING_AGE: Duration = Duration::from_secs(60 * 60);
26const MAX_IDLE_WARMING_AGE: Duration = Duration::from_secs(30 * 60);
29const MINIMUM_EXPECTED_SAVINGS: f64 = 0.05;
32const IDLE_CONTINUATION_PROBABILITY: f64 = 0.15;
35
36#[cfg(not(test))]
40fn now() -> SystemTime {
41 SystemTime::now()
42}
43
44#[cfg(test)]
45use tests::now;
46
47fn 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
53fn 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
65fn 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
79fn 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub enum CacheWarmingPhase {
106 Streaming,
108 Idle,
110}
111
112#[derive(Clone, Copy, Debug, PartialEq)]
115pub struct CacheWarmingDecision {
116 pub phase: CacheWarmingPhase,
118 pub warm_cost: f64,
120 pub miss_cost: f64,
122 pub continuation_probability: f64,
124 pub expected_savings: f64,
126 pub economics_available: bool,
128 pub warm: bool,
130}
131
132impl CacheWarmingDecision {
133 fn action(&self) -> &'static str {
134 if self.warm { "warm" } else { "stop" }
135 }
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum CacheWarmingState {
141 Inactive,
143 Scheduled,
145 Refreshing,
147}
148
149#[derive(Clone, Debug, PartialEq)]
151pub struct CacheWarmingStatus {
152 pub state: CacheWarmingState,
154 pub reason: Option<&'static str>,
156 pub next_warm_at: Option<SystemTime>,
158 pub decision: Option<CacheWarmingDecision>,
160 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
176struct Run {
178 id: u64,
180 request: Request,
183 leaf: Option<String>,
185 ttl_ms: f64,
186 delay: Duration,
187 refresh_deadline: SystemTime,
190 started: SystemTime,
191 cancel: CancellationToken,
193 phase: CacheWarmingPhase,
194 next_warm_at: SystemTime,
195 extension_override: bool,
197 refreshing: bool,
199}
200
201pub(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 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 pub fn cache_warming_mode(&self) -> CacheWarming {
251 lock(&self.inner.settings).cache_warming_mode()
252 }
253
254 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 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 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 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 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 pub(super) fn cancel_cache_warming(&self) {
366 lock(&self.inner.cache_warmer).stop("inactive", None);
367 }
368
369 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 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 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 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 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 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 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 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 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 let current = lock(&self.inner.cancel).clone().unwrap_or_default();
633 request.options.hooks = self.request_hooks(&request.model, ¤t);
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
641fn 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
686pub fn format_cache_warming_status(status: &CacheWarmingStatus, now: SystemTime) -> String {
688 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
713pub 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 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 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 type Sent = (Option<u64>, CancellationToken);
786
787 #[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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 assert_eq!(provider.caps().len(), 2);
1430 assert!(!session.has_queued());
1431 }
1432
1433 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 #[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 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 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 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 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}