Skip to main content

agent_engine/runtime/
keepwarm.rs

1//! Prompt-cache keep-warm ping (task #444).
2//!
3//! Anthropic refreshes a cache entry's TTL every time it is read ("hits and
4//! refreshes" are billed at the cache-read rate), and the lifetime counts from
5//! the START of the request that read or wrote it. Re-sending the session's
6//! exact prefix shortly before the TTL lapses keeps a large prefix warm at the
7//! read price instead of paying a full rewrite when the user comes back. The
8//! *policy* (when to ping, when to stop, what it costs) lives in the session
9//! actor (`session::cache_keepwarm`); this module builds, fingerprints and
10//! sends one ping.
11//!
12//! The ping is the request the NEXT real turn would send, minus the parts that
13//! are per-turn by construction, and goes through the SAME request builder
14//! (`ApiMethods::call_api_stream_inner` → `build_anthropic_request`):
15//!
16//! - tools, system prompt (orchestration doctrine, frozen tool catalog,
17//!   context guidance, session-scoped extension injection), thinking config,
18//!   effort, model, TTL markers and the durable history are assembled exactly
19//!   as `run_stream_tracked` + `run_stream_internal` assemble them;
20//! - the trailing message is a fixed user text block, appended request-locally
21//!   AFTER the last cache marker (`ApiOptions::keepwarm` moves the
22//!   conversational marker onto the last history message), so it never enters
23//!   the cached prefix and is never persisted;
24//! - no memory recall, no `before_message` / Inject hooks, no steering, no
25//!   context-pressure message: those only ever touch the request tail;
26//! - no `MessageHistory`, no turn, no transcript: events go to a private
27//!   channel and only the `Usage` numbers come back;
28//! - `max_tokens` is cut to the minimum the thinking mode allows
29//!   (`RequestBody::apply_keepwarm_max_tokens`; not part of the cache key),
30//!   retries are zero, and the telemetry record says `"keepwarm": true`.
31//!
32//! **Prefix fingerprint.** A ping is only correct while the next real turn's
33//! prefix is byte-identical to what is cached; on a changed prefix it would
34//! WRITE a new entry instead of reading the old one. The builder hashes the
35//! serialized prefix (`RequestBody::prefix_fingerprint`) of every keep-warm
36//! request. [`Runtime::cache_keepwarm_probe`] returns that hash without
37//! sending anything; [`Runtime::cache_keepwarm_ping`] takes the expected hash
38//! and refuses to send — inside the builder, after assembly, right before the
39//! network — when the freshly built prefix differs.
40
41use std::sync::atomic::{AtomicU16, Ordering};
42use std::sync::Arc;
43
44use serde_json::{json, Value};
45use tokio::sync::mpsc;
46use tokio_util::sync::CancellationToken;
47
48use super::api::{ApiMethods, ApiOptions, KeepWarmMode, KeepWarmRequest};
49use super::helpers::HelperMethods;
50use super::{Runtime, SessionEvent, StreamEvent};
51use crate::{Result, RuntimeError, SharedMessage};
52
53/// Output tokens a ping may produce on top of a thinking budget (or in total
54/// with thinking off / adaptive). One token: the answer is discarded.
55pub const KEEPWARM_OUTPUT_TOKENS: u64 = 1;
56
57/// The trailing user message. Sits after the last cache marker, is never
58/// persisted, and asks for the shortest possible answer with no reasoning so
59/// budgeted thinking spends as little of its budget as the model allows.
60pub const KEEPWARM_PING_TEXT: &str = "[automated prompt-cache keep-warm ping, not from the user] \
61     This needs no reasoning. Reply with exactly: ok";
62
63/// When this session's runtime last had an Anthropic request accepted
64/// (HTTP 2xx): the moment its cache entries were last written or refreshed.
65/// Taken immediately BEFORE the bytes are handed to the HTTP client, so it is
66/// never later than the provider's own request start (the TTL origin).
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct CacheAnchor {
69    /// Monotonic clock: scheduling.
70    pub mono: tokio::time::Instant,
71    /// Wall clock: the provider's TTL keeps running while this machine
72    /// sleeps; the monotonic clock does not.
73    pub wall: std::time::SystemTime,
74}
75
76impl CacheAnchor {
77    pub fn now() -> Self {
78        Self {
79            mono: tokio::time::Instant::now(),
80            wall: std::time::SystemTime::now(),
81        }
82    }
83}
84
85/// Shared "last accepted Anthropic request" slot (see [`CacheAnchor`]).
86/// Runtime clones get a FRESH cell (a clone is a different request chain,
87/// like the TTL latches); `share_ttl_latches` shares it.
88#[derive(Debug, Default)]
89pub struct CacheAnchorCell(std::sync::Mutex<Option<CacheAnchor>>);
90
91impl CacheAnchorCell {
92    pub fn stamp(&self, at: CacheAnchor) {
93        let mut slot = self
94            .0
95            .lock()
96            .unwrap_or_else(std::sync::PoisonError::into_inner);
97        if slot.map_or(true, |prev| prev.mono <= at.mono) {
98            *slot = Some(at);
99        }
100    }
101
102    pub fn get(&self) -> Option<CacheAnchor> {
103        *self
104            .0
105            .lock()
106            .unwrap_or_else(std::sync::PoisonError::into_inner)
107    }
108}
109
110/// Token usage of one ping, as reported by the provider.
111#[derive(Debug, Clone, Default, PartialEq)]
112pub struct KeepWarmUsage {
113    pub input_tokens: u64,
114    pub output_tokens: u64,
115    pub cache_read_tokens: u64,
116    pub cache_write_tokens: u64,
117    pub cache_write_5m: Option<u64>,
118    pub cache_write_1h: Option<u64>,
119    /// Provider-reported model, if any.
120    pub model: Option<String>,
121}
122
123impl KeepWarmUsage {
124    pub(crate) fn add(&mut self, event: &StreamEvent) {
125        if let StreamEvent::Session(SessionEvent::Usage {
126            input_tokens,
127            output_tokens,
128            cache_read_input_tokens,
129            cache_creation_input_tokens,
130            cache_creation_5m,
131            cache_creation_1h,
132            model,
133        }) = event
134        {
135            self.input_tokens += input_tokens;
136            self.output_tokens += output_tokens;
137            self.cache_read_tokens += cache_read_input_tokens;
138            self.cache_write_tokens += cache_creation_input_tokens;
139            if let Some(n) = cache_creation_5m {
140                *self.cache_write_5m.get_or_insert(0) += n;
141            }
142            if let Some(n) = cache_creation_1h {
143                *self.cache_write_1h.get_or_insert(0) += n;
144            }
145            if model.is_some() {
146                self.model = model.clone();
147            }
148        }
149    }
150
151    /// USD cost of the ping (cache read + any cache write + uncached input +
152    /// output incl. thinking), priced exactly like a turn's usage.
153    pub fn cost_usd(&self, fallback_model: &str) -> f64 {
154        crate::pricing::calculate_cost_optional_split(
155            self.model.as_deref().unwrap_or(fallback_model),
156            self.input_tokens,
157            self.output_tokens,
158            self.cache_read_tokens,
159            self.cache_write_tokens,
160            self.cache_write_5m,
161            self.cache_write_1h,
162        )
163    }
164}
165
166/// What a ping did.
167#[derive(Debug, Clone, PartialEq)]
168pub enum PingOutcome {
169    /// Sent and accepted; its usage is in [`PingReport::usage`].
170    Sent,
171    /// Not sent: the freshly built prefix no longer matches the expected
172    /// fingerprint (model, thinking, tools, system or history changed).
173    PrefixChanged { expected: u64, actual: u64 },
174}
175
176/// One ping's result AND whatever the provider billed for it. Usage is
177/// returned on every path — success, transient or fatal error, in-stream
178/// error under a 200 — so the caller books it all.
179#[derive(Debug, Clone, PartialEq)]
180pub struct PingReport {
181    pub result: std::result::Result<PingOutcome, PingFailure>,
182    pub usage: KeepWarmUsage,
183    /// Notices the request raised (e.g. the one-per-session silent 1h→5m
184    /// downgrade notice: the ping shares the session's latch, so the actor
185    /// must surface it).
186    pub notices: Vec<String>,
187}
188
189/// Live view of an in-flight ping, shared with the actor so an ABORTED ping
190/// (a turn started, the session ended) can still be booked: `sent` flips once
191/// the request is dispatched (the HTTP client pulls the body over an
192/// established connection, or a response arrives — never for a refused
193/// connection), `connect_failed` when `send()` could not connect, `status`
194/// holds the last HTTP status (0 = none), and `usage` accumulates Usage
195/// events as they stream in.
196#[derive(Debug, Clone, Default)]
197pub struct PingTap {
198    pub sent: Arc<std::sync::atomic::AtomicBool>,
199    pub connect_failed: Arc<std::sync::atomic::AtomicBool>,
200    pub status: Arc<AtomicU16>,
201    pub usage: Arc<std::sync::Mutex<KeepWarmUsage>>,
202}
203
204impl PingTap {
205    pub fn was_sent(&self) -> bool {
206        self.sent.load(Ordering::Relaxed)
207    }
208
209    pub fn usage(&self) -> KeepWarmUsage {
210        self.usage
211            .lock()
212            .unwrap_or_else(std::sync::PoisonError::into_inner)
213            .clone()
214    }
215
216    /// What this ping is booked at, by [`ping_booking`], and its cost:
217    /// measured usage, else `worst_usd` once dispatched, else nothing.
218    /// EVERY path that ends a ping (done Ok / Err, abort, task panic) books
219    /// through here.
220    pub fn bill(&self, model: &str, worst_usd: f64) -> PingBill {
221        let usage = self.usage();
222        let booking = ping_booking(
223            usage != KeepWarmUsage::default(),
224            self.was_sent(),
225            self.connect_failed.load(Ordering::Relaxed),
226            self.status.load(Ordering::Relaxed),
227        );
228        let cost_usd = match booking {
229            PingBooking::Measured => usage.cost_usd(model),
230            PingBooking::WorstCase => worst_usd,
231            PingBooking::Nothing => 0.0,
232        };
233        PingBill {
234            booking,
235            usage,
236            cost_usd,
237        }
238    }
239}
240
241/// How a finished or aborted ping is booked (see [`ping_booking`]).
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum PingBooking {
244    /// The provider reported usage: book exactly that.
245    Measured,
246    /// Dispatched, no usage seen: the provider may bill it — book the
247    /// period's worst case for the ping.
248    WorstCase,
249    /// Nothing can be billed: never dispatched (refused before the network,
250    /// or a connect failure with no HTTP status), or rejected outright with an
251    /// HTTP error status before any usage was reported — the provider does not
252    /// bill a request it refused (an in-stream error after a 200 arrives with
253    /// `message_start` usage and is `Measured`).
254    Nothing,
255}
256
257/// The one booking rule for a ping, on every path: usage arrived → its real
258/// cost; otherwise rejected with an HTTP error status (≥ 400) → $0; otherwise
259/// dispatched → the worst case, unless the error was a connect failure with no
260/// HTTP status; otherwise $0.
261pub fn ping_booking(
262    usage_arrived: bool,
263    sent: bool,
264    connect_failed: bool,
265    http_status: u16,
266) -> PingBooking {
267    if usage_arrived {
268        PingBooking::Measured
269    } else if http_status >= 400 {
270        PingBooking::Nothing
271    } else if sent && !(connect_failed && http_status == 0) {
272        PingBooking::WorstCase
273    } else {
274        PingBooking::Nothing
275    }
276}
277
278/// A ping's booking (see [`PingTap::bill`]).
279#[derive(Debug, Clone, PartialEq)]
280pub struct PingBill {
281    pub booking: PingBooking,
282    pub usage: KeepWarmUsage,
283    pub cost_usd: f64,
284}
285
286/// What a probe learned about the next ping, without sending anything.
287#[derive(Debug, Clone, PartialEq)]
288pub struct KeepWarmProbe {
289    /// Prefix fingerprint (see the module docs).
290    pub fingerprint: u64,
291    /// The ping's `max_tokens`: the worst-case output it can be billed for
292    /// (1, or `budget + 1` under budgeted thinking).
293    pub max_tokens: u64,
294    /// `Some(reason)` when the next real turn — this history plus an empty
295    /// user message — would roll the context over or hard-stop before its
296    /// first request (the same assessment `run_stream_internal` makes). The
297    /// cached prefix is then not what the next turn sends.
298    pub context_blocks: Option<String>,
299}
300
301/// Why a ping failed.
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub enum PingFailure {
304    /// Worth a bounded retry: 429/5xx/529, network, in-stream transient.
305    Transient(String),
306    /// Stop: any other 4xx, auth, configuration, ineligible session.
307    Fatal(String),
308    /// Aborted by the actor (a turn started, the session is ending).
309    Canceled,
310}
311
312impl std::fmt::Display for PingFailure {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        match self {
315            PingFailure::Transient(m) => write!(f, "transient: {m}"),
316            PingFailure::Fatal(m) => write!(f, "{m}"),
317            PingFailure::Canceled => f.write_str("canceled"),
318        }
319    }
320}
321
322/// Classify a ping error. `status` is the last HTTP status the transport saw
323/// (0 = none: network failure before any response).
324pub(crate) fn classify_failure(err: &RuntimeError, status: u16) -> PingFailure {
325    if matches!(err, RuntimeError::Canceled) {
326        return PingFailure::Canceled;
327    }
328    if (400..500).contains(&status) && status != 429 {
329        return PingFailure::Fatal(format!("HTTP {status}"));
330    }
331    match err {
332        RuntimeError::Api(_) | RuntimeError::ApiStatus(_) | RuntimeError::Timeout => {
333            PingFailure::Transient(if status == 0 {
334                "network".to_string()
335            } else {
336                format!("HTTP {status}")
337            })
338        }
339        other => PingFailure::Fatal(other.to_string()),
340    }
341}
342
343/// The marker moves onto the last history message, so it must be a block the
344/// API accepts `cache_control` on: a finished assistant reply ending in a
345/// non-empty text block (never a thinking block, never a dangling tool_use).
346fn tail_is_markable(last: &Value) -> bool {
347    if last["role"] != "assistant" {
348        return false;
349    }
350    match last["content"].as_array() {
351        Some(blocks) => blocks.last().is_some_and(|b| {
352            b["type"] == "text" && b["text"].as_str().is_some_and(|t| !t.is_empty())
353        }),
354        None => last["content"].as_str().is_some_and(|t| !t.is_empty()),
355    }
356}
357
358/// Whether `history` can be kept warm: it must end with a finished assistant
359/// reply (a turn that errored or was interrupted leaves nothing worth pinging).
360pub fn history_ready(history: &[SharedMessage]) -> bool {
361    history.last().is_some_and(|m| tail_is_markable(m))
362}
363
364/// Conservative token estimate of the part of the history's last message
365/// (the turn's final reply) that the cached prefix will hold: its content
366/// minus thinking blocks, which the API strips when the next request
367/// re-sends the reply (they were billed as output, never cached). Ping 1
368/// may write at most this (plus the last request's uncached input) without
369/// counting as a partial miss.
370pub fn reply_prefix_tokens(history: &[SharedMessage]) -> u64 {
371    let Some(last) = history.last() else {
372        return 0;
373    };
374    let kept = match last["content"].as_array() {
375        Some(blocks) => Value::Array(
376            blocks
377                .iter()
378                .filter(|b| !matches!(b["type"].as_str(), Some("thinking" | "redacted_thinking")))
379                .cloned()
380                .collect(),
381        ),
382        None => last["content"].clone(),
383    };
384    super::context::conservative_token_estimate(&kept.to_string())
385}
386
387impl Runtime {
388    /// Why this runtime cannot keep its cache warm, or `None` when it can.
389    /// Only the native Anthropic transport carries `cache_control`; every
390    /// TTL (5m, 1h, hybrid) and every thinking mode is supported.
391    pub fn cache_keepwarm_ineligibility(&self) -> Option<String> {
392        if !super::auth::model_is_anthropic(&self.model) {
393            return Some(format!(
394                "model {} is not on the native Anthropic transport",
395                self.model
396            ));
397        }
398        None
399    }
400
401    /// The last accepted Anthropic request of this runtime's request chain.
402    pub fn cache_anchor(&self) -> Option<CacheAnchor> {
403        self.cache_anchor.get()
404    }
405
406    /// The session's silent 1h→5m downgrade latch has fired: the server
407    /// honours only 5m whatever the configured TTL says.
408    pub fn cache_ttl_downgraded(&self) -> bool {
409        self.ttl_downgrade_notified.load(Ordering::Relaxed)
410    }
411
412    /// A request of this session has written a 1h cache entry.
413    pub fn cache_1h_honoured(&self) -> bool {
414        self.saw_1h_honored.load(Ordering::Relaxed)
415    }
416
417    /// Probe the ping the next real turn would justify for `history` (see
418    /// the module docs): its prefix fingerprint, its worst-case output, and
419    /// whether the next turn would roll the context over first. Builds the
420    /// request through the real builder and returns before anything is
421    /// sent; no network I/O.
422    pub async fn cache_keepwarm_probe(&self, history: &[SharedMessage]) -> Result<KeepWarmProbe> {
423        let cancel = CancellationToken::new();
424        let tap = PingTap::default();
425        let (value, _) = self
426            .keepwarm_request(history, &cancel, KeepWarmMode::Probe, &tap)
427            .await;
428        let value = value?;
429        let fingerprint = value["keepwarm_prefix"].as_u64().ok_or_else(|| {
430            RuntimeError::Session("keep-warm probe produced no fingerprint".into())
431        })?;
432        Ok(KeepWarmProbe {
433            fingerprint,
434            max_tokens: value["keepwarm_max_tokens"].as_u64().unwrap_or(0),
435            context_blocks: value["keepwarm_context_blocks"]
436                .as_str()
437                .map(str::to_string),
438        })
439    }
440
441    /// Send ONE keep-warm ping for `history` (the session's durable history,
442    /// unchanged) if its freshly built prefix still hashes to `expect`.
443    /// Never mutates `history`, the runtime's session state, or any persisted
444    /// state; fires no extension hooks; emits nothing to any frontend.
445    /// `tap` sees the request leave and its usage arrive while it runs.
446    pub async fn cache_keepwarm_ping(
447        &self,
448        history: &[SharedMessage],
449        expect: u64,
450        cancel: &CancellationToken,
451        tap: &PingTap,
452    ) -> PingReport {
453        let (value, notices) = self
454            .keepwarm_request(history, cancel, KeepWarmMode::Send { expect }, tap)
455            .await;
456        let result = match value {
457            Ok(value) => match value.get("keepwarm_prefix").and_then(Value::as_u64) {
458                Some(actual) => Ok(PingOutcome::PrefixChanged {
459                    expected: expect,
460                    actual,
461                }),
462                None => Ok(PingOutcome::Sent),
463            },
464            Err(e) => Err(classify_failure(&e, tap.status.load(Ordering::Relaxed))),
465        };
466        PingReport {
467            result,
468            usage: tap.usage(),
469            notices,
470        }
471    }
472
473    async fn keepwarm_request(
474        &self,
475        history: &[SharedMessage],
476        cancel: &CancellationToken,
477        mode: KeepWarmMode,
478        tap: &PingTap,
479    ) -> (Result<Value>, Vec<String>) {
480        match self
481            .keepwarm_request_inner(history, cancel, mode, tap)
482            .await
483        {
484            Ok(r) => r,
485            Err(e) => (Err(e), Vec::new()),
486        }
487    }
488
489    /// `Err` = refused before any request was built (nothing billed);
490    /// `Ok((Err, _))` = the request itself failed (usage, if any, is in `tap`).
491    async fn keepwarm_request_inner(
492        &self,
493        history: &[SharedMessage],
494        cancel: &CancellationToken,
495        mode: KeepWarmMode,
496        tap: &PingTap,
497    ) -> Result<(Result<Value>, Vec<String>)> {
498        if let Some(reason) = self.cache_keepwarm_ineligibility() {
499            return Err(RuntimeError::Config(format!(
500                "cache keep-warm skipped: {reason}"
501            )));
502        }
503        if !history_ready(history) {
504            return Err(RuntimeError::Config(
505                "cache keep-warm skipped: history does not end with a finished assistant reply"
506                    .into(),
507            ));
508        }
509        let (durability_blocked, context_enabled) = {
510            let state = self
511                .continuation
512                .lock()
513                .unwrap_or_else(std::sync::PoisonError::into_inner);
514            (state.durability_blocked, state.enabled())
515        };
516        if durability_blocked {
517            return Err(RuntimeError::Session(
518                "cache keep-warm skipped: context head save is unresolved".into(),
519            ));
520        }
521
522        // Same preflight as a real turn (run_stream_tracked). A probe sends
523        // nothing, so it never needs a token refresh.
524        self.validate_request_preflight().await?;
525        let anthropic_execution_plan = self.authorized_anthropic_plan().await?;
526        if matches!(mode, KeepWarmMode::Send { .. }) {
527            self.refresh_if_needed().await?;
528        }
529
530        // System prompt: run_stream_tracked → run_stream_internal, verbatim.
531        let system_prompt = self.effective_system_prompt().await;
532        let system_prompt = if context_enabled {
533            Some(format!(
534                "{}\n\n{}",
535                system_prompt.as_deref().unwrap_or_default(),
536                super::continuation::GUIDANCE
537            ))
538        } else {
539            system_prompt
540        };
541        let session_injection = match self.session_id.as_deref() {
542            Some(id) => self.hook_bus.session_injection_for(id).await,
543            None => None,
544        };
545        let system_prompt = match session_injection {
546            Some(content) => Some(super::stream::wrap_extension_context(
547                system_prompt.as_deref().unwrap_or_default(),
548                &content,
549            )),
550            None => system_prompt,
551        };
552
553        // Messages: the durable history plus one request-local trailing user
554        // message. Same request-local image cap as a real round.
555        let mut request: Vec<SharedMessage> = history.to_vec();
556        request.push(Arc::new(
557            json!({"role": "user", "content": KEEPWARM_PING_TEXT}),
558        ));
559        let capped;
560        let request_messages: &[SharedMessage] = match super::stream::cap_history_image_bytes(
561            &request,
562            super::stream::HISTORY_IMAGE_BYTE_CAP,
563        ) {
564            Some(c) => {
565                capped = c;
566                &capped
567            }
568            None => &request,
569        };
570
571        // Tools: a snapshot of the live registry (the previous turn already
572        // applied its idempotent setup), projected exactly like a stream round
573        // when progressive disclosure is on — read-only: the retained session
574        // tool set is never written.
575        let tools_snapshot = self.tools.read().await.clone();
576        let (request_tools_schema, anthropic_tool_split) = if self.progressive_tool_disclosure {
577            let fresh = super::continuation::context_tool_set(
578                self.host_tool_session.clone(),
579                tools_snapshot.catalog(),
580                true,
581                context_enabled,
582            );
583            // The SAME selection a turn start makes (`retain_session_tool_set`),
584            // through its pure half: nothing is written back.
585            let retained = self
586                .retained_tool_set
587                .lock()
588                .unwrap_or_else(std::sync::PoisonError::into_inner)
589                .clone();
590            let existing = retained.as_ref().map(|shared| {
591                shared
592                    .read()
593                    .unwrap_or_else(std::sync::PoisonError::into_inner)
594            });
595            let (set, _, _) = super::stream::select_turn_start_tool_set(
596                existing.as_deref(),
597                fresh,
598                tools_snapshot.catalog(),
599                true,
600            );
601            drop(existing);
602            let schema = tools_snapshot.session_tools_schema(&set).schema;
603            let split = self.deferred_tool_loading.then(|| {
604                let referenced = HelperMethods::referenced_tool_ids(request_messages);
605                Arc::new(tools_snapshot.deferred_tool_split(&set, &referenced))
606            });
607            (Some(Arc::new(schema)), split)
608        } else {
609            (None, None)
610        };
611
612        let options = ApiOptions {
613            memory_backend: None,
614            use_1m_context: self.context_window_override == Some(1_000_000),
615            cache_ttl: self.cache_ttl,
616            // The SESSION's latch: a ping that sees the silent 1h→5m
617            // downgrade raises the one notice (the actor surfaces it) and
618            // the actor schedules by the 5m TTL the server honours.
619            ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
620            saw_1h_honored: self.saw_1h_honored.clone(),
621            credential_source: self.credential_source.clone(),
622            token_cache: self.token_cache.clone(),
623            anthropic_base_url: None,
624            anthropic_execution_plan,
625            codex_request_role: self.codex_request_role(),
626            // The base context: a ping must never consume `/trace next`.
627            trace: self.trace_ctx.clone(),
628            request_correlation: None,
629            suppress_stream_deltas: true,
630            telemetry: self.telemetry_writer.clone(),
631            tool_session_id: Some(self.host_tool_session.clone()),
632            session_tool_set: None,
633            request_tools_schema: request_tools_schema.clone(),
634            anthropic_tool_split,
635            usage_counters: None,
636            // A successful ping refreshes the cache: it moves the anchor the
637            // next ping is scheduled from.
638            cache_anchor: Some(Arc::clone(&self.cache_anchor)),
639            keepwarm: Some(KeepWarmRequest {
640                mode,
641                status: Arc::clone(&tap.status),
642                sent: Arc::clone(&tap.sent),
643                connect_failed: Arc::clone(&tap.connect_failed),
644            }),
645        };
646
647        let (tx, mut rx) = mpsc::unbounded_channel();
648        let call = ApiMethods::call_api_stream_inner(
649            &self.auth,
650            &self.client,
651            &self.model,
652            &tools_snapshot,
653            &system_prompt,
654            self.thinking_budget,
655            self.reasoning_level(),
656            request_messages,
657            tx,
658            cancel,
659            0,
660            0,
661            &options,
662            self.telemetry_level,
663        );
664        tokio::pin!(call);
665        // Drain events WHILE the request runs: usage lands in the tap as it
666        // arrives, so an aborted ping can still be booked.
667        let mut notices = Vec::new();
668        let mut on_event = |event: StreamEvent| {
669            if let StreamEvent::Session(SessionEvent::Notice(n)) = &event {
670                notices.push(n.clone());
671            }
672            tap.usage
673                .lock()
674                .unwrap_or_else(std::sync::PoisonError::into_inner)
675                .add(&event);
676        };
677        let mut result = loop {
678            tokio::select! {
679                r = &mut call => break r,
680                Some(event) = rx.recv() => on_event(event),
681            }
682        };
683        while let Ok(event) = rx.try_recv() {
684            on_event(event);
685        }
686        // Probe: also run the next turn's pre-request context assessment.
687        if let (Ok(value), KeepWarmMode::Probe) = (&mut result, mode) {
688            if context_enabled {
689                if let Some(why) = self.keepwarm_context_blocks(
690                    history,
691                    system_prompt.as_deref(),
692                    request_tools_schema.as_deref().map(|s| s.as_slice()),
693                    &tools_snapshot,
694                ) {
695                    value["keepwarm_context_blocks"] = Value::String(why);
696                }
697            }
698        }
699        Ok((result, notices))
700    }
701
702    /// The pre-request context assessment of `run_stream_internal`, for the
703    /// next turn = `history` + an empty user message, WITHOUT committing the
704    /// policy's next state. `Some(reason)` on Rollover / HardStop.
705    ///
706    /// Mirrors the real round exactly: it assesses the round's final
707    /// `request_messages` — after the request-local history image cap — via
708    /// the same [`super::stream::pre_request_context_budget`].
709    fn keepwarm_context_blocks(
710        &self,
711        history: &[SharedMessage],
712        system_prompt: Option<&str>,
713        projected_schema: Option<&[Value]>,
714        tools_snapshot: &crate::ToolRegistry,
715    ) -> Option<String> {
716        use agent_core::core::context_policy::{assess_context, ContextAction};
717        let mut next: Vec<SharedMessage> = history.to_vec();
718        next.push(Arc::new(json!({"role": "user", "content": ""})));
719        let capped =
720            super::stream::cap_history_image_bytes(&next, super::stream::HISTORY_IMAGE_BYTE_CAP);
721        let request_messages: &[SharedMessage] = capped.as_deref().unwrap_or(&next[..]);
722        let fallback_schema;
723        let schema = match projected_schema {
724            Some(s) => s,
725            None => {
726                fallback_schema = tools_snapshot.tools_schema();
727                fallback_schema.as_slice()
728            }
729        };
730        let (assessment, budget) = super::stream::pre_request_context_budget(
731            &self.model,
732            self.context_window(),
733            system_prompt,
734            schema,
735            request_messages,
736            self.thinking_budget as u64,
737            self.max_tool_output as u64,
738        );
739        let state = self
740            .continuation
741            .lock()
742            .unwrap_or_else(std::sync::PoisonError::into_inner);
743        let decision = assess_context(&state.config, &state.policy, budget);
744        match decision.action {
745            ContextAction::Rollover => Some(format!(
746                "the next turn would roll the context over ({} tokens used)",
747                assessment.used_tokens()
748            )),
749            ContextAction::HardStop => Some(format!(
750                "the next turn would hit the context hard limit ({} tokens used)",
751                assessment.used_tokens()
752            )),
753            _ => None,
754        }
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use crate::core::config::CacheTtl;
762    use crate::extensions::hooks::events::{HookEvent, HookKind, HookResult};
763    use crate::extensions::permissions::{Permission, PermissionSet};
764    use agent_core::reasoning::ReasoningLevel;
765    use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Router};
766    use futures::StreamExt;
767    use std::sync::atomic::AtomicUsize;
768    use std::sync::Mutex;
769
770    const SSE_HI: &str = concat!(
771        "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_kw\",\"type\":\"message\",",
772        "\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-4-7\",\"stop_reason\":null,",
773        "\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0,",
774        "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}}\n\n",
775        "data: {\"type\":\"content_block_start\",\"index\":0,",
776        "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
777        "data: {\"type\":\"content_block_delta\",\"index\":0,",
778        "\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n",
779        "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
780        "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",",
781        "\"stop_sequence\":null},\"usage\":{\"input_tokens\":10,\"output_tokens\":1,",
782        "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}\n\n",
783        "data: {\"type\":\"message_stop\"}\n\n",
784    );
785
786    /// A 200 stream that reports usage, then fails in-stream (overloaded).
787    const SSE_USAGE_THEN_ERROR: &str = concat!(
788        "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_kw\",\"type\":\"message\",",
789        "\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-4-7\",\"stop_reason\":null,",
790        "\"stop_sequence\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":0,",
791        "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5000}}}\n\n",
792        "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":null,",
793        "\"stop_sequence\":null},\"usage\":{\"input_tokens\":12,\"output_tokens\":1,",
794        "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5000}}\n\n",
795        "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",",
796        "\"message\":\"synthetic\"}}\n\n",
797    );
798
799    #[derive(Clone)]
800    struct Mock {
801        bodies: Arc<Mutex<Vec<String>>>,
802        /// HTTP status served (200 = `sse`).
803        status: u16,
804        sse: &'static str,
805    }
806
807    async fn spawn_mock() -> (String, Mock) {
808        spawn_mock_full(200, SSE_HI).await
809    }
810
811    async fn spawn_mock_with(status: u16) -> (String, Mock) {
812        spawn_mock_full(status, SSE_HI).await
813    }
814
815    async fn spawn_mock_full(status: u16, sse: &'static str) -> (String, Mock) {
816        let mock = Mock {
817            bodies: Arc::default(),
818            status,
819            sse,
820        };
821        let app = Router::new()
822            .route(
823                "/v1/messages",
824                post(|State(m): State<Mock>, body: String| async move {
825                    m.bodies.lock().unwrap().push(body);
826                    if m.status == 200 {
827                        (
828                            StatusCode::OK,
829                            [("content-type", "text/event-stream")],
830                            m.sse.to_string(),
831                        )
832                            .into_response()
833                    } else {
834                        (
835                            StatusCode::from_u16(m.status).unwrap(),
836                            [("content-type", "application/json")],
837                            r#"{"type":"error","error":{"type":"api_error","message":"synthetic"}}"#
838                                .to_string(),
839                        )
840                            .into_response()
841                    }
842                }),
843            )
844            .layer(axum::extract::DefaultBodyLimit::max(64 * 1024 * 1024))
845            .with_state(mock.clone());
846        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
847        let addr = listener.local_addr().unwrap();
848        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
849        (format!("http://{addr}"), mock)
850    }
851
852    /// `before_message` hook that counts its calls and injects per-turn text.
853    struct CountingInject(Arc<AtomicUsize>);
854    #[async_trait::async_trait]
855    impl crate::extensions::runtime::ExtensionHandler for CountingInject {
856        fn id(&self) -> &str {
857            "keepwarm-counting-inject"
858        }
859        async fn handle(&self, _event: &HookEvent) -> HookResult {
860            self.0.fetch_add(1, Ordering::SeqCst);
861            HookResult::Inject {
862                content: "PER-TURN-INJECTED-CONTEXT".into(),
863            }
864        }
865        async fn shutdown(&self) {}
866    }
867
868    /// Run one real turn and return its final durable history.
869    async fn real_turn(rt: &Runtime, messages: Vec<SharedMessage>) -> Vec<SharedMessage> {
870        let mut stream = rt
871            .run_stream_with_messages(messages, CancellationToken::new(), None, None, false)
872            .await;
873        let mut history = None;
874        while let Some(event) = stream.next().await {
875            match event {
876                StreamEvent::Session(SessionEvent::MessageHistory(h)) => history = Some(h),
877                StreamEvent::Session(SessionEvent::Error(e)) => panic!("turn failed: {e:?}"),
878                _ => {}
879            }
880        }
881        history.expect("turn published its history")
882    }
883
884    fn user(text: &str) -> SharedMessage {
885        Arc::new(json!({"role": "user", "content": text}))
886    }
887
888    fn canon(v: &Value) -> String {
889        serde_json::to_string(v).unwrap()
890    }
891
892    fn strip_cache_control(mut msg: Value) -> Value {
893        if let Some(blocks) = msg["content"].as_array_mut() {
894            for b in blocks {
895                if let Some(obj) = b.as_object_mut() {
896                    obj.remove("cache_control");
897                }
898            }
899        }
900        msg
901    }
902
903    fn marker_count(v: &Value) -> usize {
904        match v {
905            Value::Object(map) => {
906                usize::from(map.contains_key("cache_control"))
907                    + map.values().map(marker_count).sum::<usize>()
908            }
909            Value::Array(items) => items.iter().map(marker_count).sum(),
910            _ => 0,
911        }
912    }
913
914    /// What [`ping_then_next_turn`] observed.
915    struct Observed {
916        ping: Value,
917        real: Value,
918        raw_ping: String,
919        history_len: usize,
920        usage: KeepWarmUsage,
921        /// `before_message` calls before the ping, after it, after the next turn.
922        hook_calls: (usize, usize, usize),
923    }
924
925    /// Previous turn → probe → keep-warm ping → the real next turn, on one
926    /// runtime. Also proves: the probe sends nothing and its fingerprint is
927    /// the one the ping accepts; the ping does not touch history; accepted
928    /// requests move the cache anchor.
929    async fn ping_then_next_turn(rt: &Runtime) -> Observed {
930        let (url, mock) = spawn_mock().await;
931        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
932        let calls = Arc::new(AtomicUsize::new(0));
933        let mut perms = PermissionSet::new();
934        perms.grant(Permission::LlmContent);
935        rt.hook_bus()
936            .subscribe(
937                HookKind::BeforeMessage,
938                Arc::new(CountingInject(Arc::clone(&calls))),
939                None,
940                None,
941                perms,
942            )
943            .await
944            .unwrap();
945
946        assert_eq!(rt.cache_anchor(), None, "no request has run yet");
947        // The previous turn: the session is idle afterwards.
948        let history = real_turn(rt, vec![user("hello")]).await;
949        assert_eq!(history.last().unwrap()["role"], "assistant");
950        let turn_anchor = rt
951            .cache_anchor()
952            .expect("an accepted turn request stamps the anchor");
953        let before_ping = calls.load(Ordering::SeqCst);
954        let snapshot = history.clone();
955
956        let fingerprint = rt
957            .cache_keepwarm_probe(&history)
958            .await
959            .expect("probe")
960            .fingerprint;
961        assert_eq!(
962            mock.bodies.lock().unwrap().len(),
963            1,
964            "a probe sends nothing"
965        );
966        assert_eq!(
967            rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
968            fingerprint,
969            "the fingerprint is deterministic"
970        );
971
972        let tap = PingTap::default();
973        let report = rt
974            .cache_keepwarm_ping(&history, fingerprint, &CancellationToken::new(), &tap)
975            .await;
976        assert_eq!(report.result, Ok(PingOutcome::Sent), "{report:?}");
977        assert!(tap.was_sent(), "the tap saw the request leave");
978        assert_eq!(tap.usage(), report.usage, "the tap saw the usage");
979        let usage = report.usage;
980        assert!(
981            usage.output_tokens >= 1,
982            "usage comes back from the private channel"
983        );
984        let ping_anchor = rt.cache_anchor().expect("anchor");
985        assert!(
986            ping_anchor.mono >= turn_anchor.mono,
987            "the ping moved the anchor"
988        );
989        let after_ping = calls.load(Ordering::SeqCst);
990        assert_eq!(history, snapshot, "the ping must not mutate history");
991
992        // The real next turn.
993        let mut next = history.clone();
994        next.push(user("next question"));
995        real_turn(rt, next).await;
996        let after_next = calls.load(Ordering::SeqCst);
997
998        let bodies = mock.bodies.lock().unwrap().clone();
999        assert_eq!(bodies.len(), 3, "previous turn, ping, next turn");
1000        Observed {
1001            ping: serde_json::from_str(&bodies[1]).unwrap(),
1002            real: serde_json::from_str(&bodies[2]).unwrap(),
1003            raw_ping: bodies[1].clone(),
1004            history_len: history.len(),
1005            usage,
1006            hook_calls: (before_ping, after_ping, after_next),
1007        }
1008    }
1009
1010    fn assert_prefix_identical(
1011        ping: &Value,
1012        real: &Value,
1013        n: usize,
1014        history_marker: &Value,
1015        ping_max_tokens: u64,
1016    ) {
1017        // Everything the cache key covers besides messages.
1018        for key in [
1019            "model",
1020            "tools",
1021            "system",
1022            "thinking",
1023            "output_config",
1024            "stream",
1025        ] {
1026            assert_eq!(
1027                canon(&ping[key]),
1028                canon(&real[key]),
1029                "`{key}` differs between ping and the real next turn"
1030            );
1031        }
1032        let pm = ping["messages"].as_array().unwrap();
1033        let rm = real["messages"].as_array().unwrap();
1034        assert_eq!(pm.len(), n + 1, "history + one trailing ping message");
1035        assert!(rm.len() > n);
1036        // History before the last message: byte-identical as sent.
1037        for i in 0..n - 1 {
1038            assert_eq!(canon(&pm[i]), canon(&rm[i]), "message {i} differs");
1039        }
1040        // The last history message: identical except the ping's marker,
1041        // which carries the history TTL (the governing one).
1042        let last = &pm[n - 1];
1043        let marker = &last["content"].as_array().unwrap().last().unwrap()["cache_control"];
1044        assert_eq!(marker, history_marker);
1045        assert_eq!(
1046            canon(&strip_cache_control(last.clone())),
1047            canon(&rm[n - 1]),
1048            "last history message differs beyond the cache marker"
1049        );
1050        // The trailing ping message sits AFTER the last breakpoint.
1051        assert_eq!(marker_count(&pm[n]), 0, "ping message must be unmarked");
1052        assert_eq!(pm[n]["role"], "user");
1053        assert_eq!(pm[n]["content"], KEEPWARM_PING_TEXT);
1054        // Output is cut; the real turn keeps its own max_tokens.
1055        assert_eq!(ping["max_tokens"], json!(ping_max_tokens));
1056        assert!(real["max_tokens"].as_u64().unwrap() > ping_max_tokens);
1057        assert!(
1058            marker_count(ping) <= 4,
1059            "Anthropic allows at most 4 breakpoints"
1060        );
1061    }
1062
1063    fn marker_1h() -> Value {
1064        json!({"type": "ephemeral", "ttl": "1h"})
1065    }
1066
1067    fn marker_5m() -> Value {
1068        json!({"type": "ephemeral"})
1069    }
1070
1071    fn opus(ttl: CacheTtl) -> Runtime {
1072        let mut rt = Runtime::new_headless();
1073        rt.set_model("claude-opus-4-7".to_string());
1074        assert_eq!(rt.model(), "claude-opus-4-7");
1075        rt.set_reasoning_level_explicit(ReasoningLevel::High);
1076        rt.set_cache_ttl(ttl);
1077        rt.set_system_prompt("You are a keep-warm test.".into());
1078        assert_eq!(rt.cache_keepwarm_ineligibility(), None);
1079        rt
1080    }
1081
1082    /// Adaptive thinking (Opus 4.7), 1h TTL: same thinking + effort, prefix
1083    /// through the last marker matches the real next turn, no hook ran for
1084    /// the ping, output capped at one token.
1085    #[tokio::test]
1086    #[serial_test::serial(synaps_base_dir)]
1087    async fn ping_prefix_matches_next_turn_with_adaptive_thinking() {
1088        let _base = crate::test_env::BaseDirGuard::new();
1089        let rt = opus(CacheTtl::OneHour);
1090
1091        let o = ping_then_next_turn(&rt).await;
1092        assert!(
1093            !o.ping["thinking"].is_null(),
1094            "thinking must stay on: {}",
1095            o.ping
1096        );
1097        assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_1h(), 1);
1098
1099        // Hooks: live for real turns, never for the ping.
1100        let (before, after_ping, after_next) = o.hook_calls;
1101        assert!(
1102            before >= 1,
1103            "control: the previous turn fired before_message"
1104        );
1105        assert_eq!(after_ping, before, "the ping fired a hook");
1106        assert!(
1107            after_next > after_ping,
1108            "control: the next turn fired before_message"
1109        );
1110        assert!(!o.raw_ping.contains("PER-TURN-INJECTED-CONTEXT"));
1111        assert!(!o.raw_ping.contains("Extension context"));
1112        // Control: the real next turn DID carry the injection — in its tail.
1113        assert!(canon(&o.real).contains("PER-TURN-INJECTED-CONTEXT"));
1114    }
1115
1116    /// 5m TTL is first-class: same identity, bare ephemeral marker.
1117    #[tokio::test]
1118    #[serial_test::serial(synaps_base_dir)]
1119    async fn ping_prefix_matches_next_turn_with_5m_ttl() {
1120        let _base = crate::test_env::BaseDirGuard::new();
1121        let rt = opus(CacheTtl::FiveMinutes);
1122        let o = ping_then_next_turn(&rt).await;
1123        assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_5m(), 1);
1124    }
1125
1126    /// Hybrid: the history marker is the 5m one (it governs the interval);
1127    /// tools/system keep their 1h markers byte-identically.
1128    #[tokio::test]
1129    #[serial_test::serial(synaps_base_dir)]
1130    async fn ping_prefix_matches_next_turn_with_hybrid_ttl() {
1131        let _base = crate::test_env::BaseDirGuard::new();
1132        let rt = opus(CacheTtl::Hybrid);
1133        let o = ping_then_next_turn(&rt).await;
1134        assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_5m(), 1);
1135        assert!(
1136            canon(&o.ping["system"]).contains("\"ttl\":\"1h\""),
1137            "hybrid keeps the 1h stable-prefix marker: {}",
1138            o.ping["system"]
1139        );
1140    }
1141
1142    /// Thinking off on a budgeted-thinking model.
1143    #[tokio::test]
1144    #[serial_test::serial(synaps_base_dir)]
1145    async fn ping_prefix_matches_next_turn_with_thinking_off() {
1146        let _base = crate::test_env::BaseDirGuard::new();
1147        let mut rt = Runtime::new_headless();
1148        rt.set_model("claude-sonnet-4-6".to_string());
1149        rt.set_reasoning_level_explicit(ReasoningLevel::Off);
1150        rt.set_cache_ttl(CacheTtl::OneHour);
1151        assert_eq!(rt.cache_keepwarm_ineligibility(), None);
1152
1153        let o = ping_then_next_turn(&rt).await;
1154        assert!(o.ping.get("thinking").is_none(), "thinking off is omitted");
1155        assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_1h(), 1);
1156    }
1157
1158    /// Budgeted thinking (Sonnet 4.6, `type: enabled`) is SUPPORTED: the
1159    /// budget is part of the cache key and stays; `max_tokens` (not part of
1160    /// the key) is `budget + 1`, the API's minimum; the ping is costed from
1161    /// its measured usage. Under 5m TTL here.
1162    #[tokio::test]
1163    #[serial_test::serial(synaps_base_dir)]
1164    async fn budgeted_thinking_ping_keeps_budget_and_is_costed() {
1165        let _base = crate::test_env::BaseDirGuard::new();
1166        let mut rt = Runtime::new_headless();
1167        rt.set_model("claude-sonnet-4-6".to_string());
1168        rt.set_reasoning_level_explicit(ReasoningLevel::High);
1169        rt.set_cache_ttl(CacheTtl::FiveMinutes);
1170        assert_eq!(
1171            rt.cache_keepwarm_ineligibility(),
1172            None,
1173            "budgeted thinking is eligible"
1174        );
1175
1176        let o = ping_then_next_turn(&rt).await;
1177        assert_eq!(o.ping["thinking"]["type"], "enabled", "{}", o.ping);
1178        let budget = o.ping["thinking"]["budget_tokens"]
1179            .as_u64()
1180            .expect("budget");
1181        assert_eq!(canon(&o.ping["thinking"]), canon(&o.real["thinking"]));
1182        assert_prefix_identical(
1183            &o.ping,
1184            &o.real,
1185            o.history_len,
1186            &marker_5m(),
1187            budget + KEEPWARM_OUTPUT_TOKENS,
1188        );
1189        assert!(
1190            o.usage.cost_usd("claude-sonnet-4-6") > 0.0,
1191            "the ping is costed"
1192        );
1193    }
1194
1195    /// A changed prefix is never sent: model, thinking level, system prompt
1196    /// and history changes all flip the fingerprint, the ping reports
1197    /// `PrefixChanged`, and the provider sees no request.
1198    #[tokio::test]
1199    #[serial_test::serial(synaps_base_dir)]
1200    async fn changed_prefix_is_refused_before_the_network() {
1201        let _base = crate::test_env::BaseDirGuard::new();
1202        let (url, mock) = spawn_mock().await;
1203        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1204        let mut rt = opus(CacheTtl::FiveMinutes);
1205        let history = real_turn(&rt, vec![user("hello")]).await;
1206        let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1207        let sent = mock.bodies.lock().unwrap().len();
1208
1209        // History grew (e.g. a compaction / /clear / a new turn).
1210        let mut longer = history.clone();
1211        longer.push(user("more"));
1212        longer.push(Arc::new(
1213            json!({"role": "assistant", "content": [{"type": "text", "text": "x"}]}),
1214        ));
1215        let changed = |r: PingReport| matches!(r.result, Ok(PingOutcome::PrefixChanged { .. }));
1216        let tap = PingTap::default();
1217        assert!(changed(
1218            rt.cache_keepwarm_ping(&longer, fp, &CancellationToken::new(), &tap)
1219                .await
1220        ));
1221
1222        rt.set_system_prompt("A different system prompt.".into());
1223        assert_ne!(
1224            rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1225            fp
1226        );
1227        assert!(changed(
1228            rt.cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &tap)
1229                .await
1230        ));
1231        rt.set_system_prompt("You are a keep-warm test.".into());
1232        assert_eq!(
1233            rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1234            fp,
1235            "restored"
1236        );
1237
1238        rt.set_reasoning_level_explicit(ReasoningLevel::Low);
1239        assert_ne!(
1240            rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1241            fp,
1242            "effort is in the key"
1243        );
1244        rt.set_reasoning_level_explicit(ReasoningLevel::High);
1245
1246        rt.set_model("claude-opus-4-6".to_string());
1247        assert_eq!(rt.model(), "claude-opus-4-6");
1248        assert_ne!(
1249            rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1250            fp,
1251            "model is in the key"
1252        );
1253
1254        assert_eq!(
1255            mock.bodies.lock().unwrap().len(),
1256            sent,
1257            "no refused ping reached the provider"
1258        );
1259        assert!(!tap.was_sent(), "a refused ping never leaves");
1260    }
1261
1262    #[tokio::test]
1263    #[serial_test::serial(synaps_base_dir)]
1264    async fn http_4xx_is_fatal_and_5xx_is_transient() {
1265        let _base = crate::test_env::BaseDirGuard::new();
1266        let history = vec![
1267            user("hello"),
1268            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1269        ];
1270        for (status, fatal) in [(400u16, true), (404, true), (500, false), (429, false)] {
1271            let (url, mock) = spawn_mock_with(status).await;
1272            let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1273            let rt = opus(CacheTtl::FiveMinutes);
1274            let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1275            let err = rt
1276                .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &PingTap::default())
1277                .await
1278                .result
1279                .expect_err("non-2xx fails");
1280            assert_eq!(
1281                matches!(err, PingFailure::Fatal(_)),
1282                fatal,
1283                "HTTP {status}: {err:?}"
1284            );
1285            assert_eq!(
1286                mock.bodies.lock().unwrap().len(),
1287                1,
1288                "HTTP {status}: no retry"
1289            );
1290            assert_eq!(
1291                rt.cache_anchor(),
1292                None,
1293                "a rejected request refreshes nothing"
1294            );
1295        }
1296    }
1297
1298    /// The prefix-identity proof for one more request shape: the ping and
1299    /// the real next turn send identical model/tools/system/thinking/effort
1300    /// and history through the marker.
1301    async fn assert_identity_1h(rt: &Runtime) -> Observed {
1302        let o = ping_then_next_turn(rt).await;
1303        assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_1h(), 1);
1304        o
1305    }
1306
1307    /// Progressive tool disclosure: the tool schema is the projection of the
1308    /// retained session tool set, selected exactly as a turn start selects
1309    /// it (`select_turn_start_tool_set`).
1310    #[tokio::test]
1311    #[serial_test::serial(synaps_base_dir)]
1312    async fn ping_prefix_matches_next_turn_with_progressive_disclosure() {
1313        let _base = crate::test_env::BaseDirGuard::new();
1314        let mut rt = opus(CacheTtl::OneHour);
1315        rt.progressive_tool_disclosure = true;
1316        assert_identity_1h(&rt).await;
1317    }
1318
1319    /// Deferred tool loading on top of progressive disclosure: the
1320    /// loaded/deferred split is part of the identity.
1321    #[tokio::test]
1322    #[serial_test::serial(synaps_base_dir)]
1323    async fn ping_prefix_matches_next_turn_with_deferred_tools() {
1324        let _base = crate::test_env::BaseDirGuard::new();
1325        let mut rt = opus(CacheTtl::OneHour);
1326        rt.progressive_tool_disclosure = true;
1327        rt.deferred_tool_loading = true;
1328        assert_identity_1h(&rt).await;
1329    }
1330
1331    /// Context management on: the guidance is appended to the system prompt
1332    /// of both.
1333    #[tokio::test]
1334    #[serial_test::serial(synaps_base_dir)]
1335    async fn ping_prefix_matches_next_turn_with_context_management_guidance() {
1336        let _base = crate::test_env::BaseDirGuard::new();
1337        let rt = opus(CacheTtl::OneHour);
1338        rt.continuation.lock().unwrap().config.mode =
1339            crate::core::config::ContextManagementMode::Auto;
1340        let o = assert_identity_1h(&rt).await;
1341        assert!(
1342            canon(&o.ping["system"]).contains("Work normally."),
1343            "guidance present: {}",
1344            o.ping["system"]
1345        );
1346    }
1347
1348    /// A session-scoped extension injection rides in the system prompt of
1349    /// both (unlike the per-turn `before_message` injection, which is tail).
1350    #[tokio::test]
1351    #[serial_test::serial(synaps_base_dir)]
1352    async fn ping_prefix_matches_next_turn_with_session_injection() {
1353        let _base = crate::test_env::BaseDirGuard::new();
1354        let mut rt = opus(CacheTtl::OneHour);
1355        rt.set_session_id(Some("kw-session".into()));
1356        rt.hook_bus()
1357            .set_session_injection_for("kw-session", "SESSION-SCOPED-INJECTION".into())
1358            .await;
1359        let o = assert_identity_1h(&rt).await;
1360        assert!(canon(&o.ping["system"]).contains("SESSION-SCOPED-INJECTION"));
1361    }
1362
1363    /// OAuth: the Claude Code transport identity and the extra system blocks
1364    /// are identical on both.
1365    #[tokio::test]
1366    #[serial_test::serial(synaps_base_dir)]
1367    async fn ping_prefix_matches_next_turn_with_oauth_system_blocks() {
1368        let _base = crate::test_env::BaseDirGuard::new();
1369        let mut rt = opus(CacheTtl::OneHour);
1370        rt.auth = Arc::new(tokio::sync::RwLock::new(super::super::types::AuthState {
1371            auth_token: "oauth-test-token".to_string(),
1372            auth_type: "oauth".to_string(),
1373            refresh_token: None,
1374            token_expires: Some(crate::epoch_millis() + 3_600_000),
1375        }));
1376        let o = assert_identity_1h(&rt).await;
1377        assert!(
1378            canon(&o.ping["system"]).contains("You are Claude Code"),
1379            "{}",
1380            o.ping["system"]
1381        );
1382    }
1383
1384    /// Context rollover ahead: the probe runs the next turn's pre-request
1385    /// assessment (history + an empty user message) and reports a Rollover /
1386    /// HardStop without committing any policy state.
1387    #[tokio::test]
1388    #[serial_test::serial(synaps_base_dir)]
1389    async fn probe_reports_a_context_rollover_ahead() {
1390        let _base = crate::test_env::BaseDirGuard::new();
1391        let (url, _mock) = spawn_mock().await;
1392        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1393        let rt = opus(CacheTtl::OneHour);
1394        rt.continuation.lock().unwrap().config.mode =
1395            crate::core::config::ContextManagementMode::Auto;
1396        let history = real_turn(&rt, vec![user("hello")]).await;
1397        let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1398        assert_eq!(probe.context_blocks, None, "plenty of room");
1399        let before = rt.continuation.lock().unwrap().policy;
1400        // The next round needs more headroom than the window has left.
1401        rt.continuation.lock().unwrap().config.reserve_tokens = 10_000_000;
1402        let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1403        assert!(probe.context_blocks.is_some(), "{probe:?}");
1404        assert_eq!(
1405            rt.continuation.lock().unwrap().policy,
1406            before,
1407            "the probe never commits policy state"
1408        );
1409        // Context management off: no assessment, like a real turn.
1410        rt.continuation.lock().unwrap().config.mode =
1411            crate::core::config::ContextManagementMode::Off;
1412        assert_eq!(
1413            rt.cache_keepwarm_probe(&history)
1414                .await
1415                .unwrap()
1416                .context_blocks,
1417            None
1418        );
1419    }
1420
1421    /// The probe reports the ping's real `max_tokens` (worst-case output).
1422    #[tokio::test]
1423    #[serial_test::serial(synaps_base_dir)]
1424    async fn probe_reports_max_tokens() {
1425        let _base = crate::test_env::BaseDirGuard::new();
1426        let history = vec![
1427            user("hello"),
1428            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1429        ];
1430        let rt = opus(CacheTtl::OneHour);
1431        assert_eq!(
1432            rt.cache_keepwarm_probe(&history).await.unwrap().max_tokens,
1433            1
1434        );
1435        let mut rt = Runtime::new_headless();
1436        rt.set_model("claude-sonnet-4-6".to_string());
1437        rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
1438        let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1439        let budget = u64::from(rt.thinking_budget);
1440        assert!(budget >= 16_384, "budgeted thinking at xhigh: {budget}");
1441        assert_eq!(
1442            probe.max_tokens,
1443            budget + KEEPWARM_OUTPUT_TOKENS,
1444            "{probe:?}"
1445        );
1446    }
1447
1448    /// Usage is returned on EVERY path: a ping that was billed and then
1449    /// failed in-stream still reports what it cost (and so does the tap).
1450    #[tokio::test]
1451    #[serial_test::serial(synaps_base_dir)]
1452    async fn ping_usage_is_returned_on_error() {
1453        let _base = crate::test_env::BaseDirGuard::new();
1454        let (url, mock) = spawn_mock_full(200, SSE_USAGE_THEN_ERROR).await;
1455        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1456        let history = vec![
1457            user("hello"),
1458            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1459        ];
1460        let rt = opus(CacheTtl::OneHour);
1461        let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1462        let tap = PingTap::default();
1463        let report = rt
1464            .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &tap)
1465            .await;
1466        assert!(
1467            matches!(report.result, Err(PingFailure::Transient(_))),
1468            "{report:?}"
1469        );
1470        assert_eq!(report.usage.cache_read_tokens, 5_000, "{report:?}");
1471        assert!(report.usage.cost_usd("claude-opus-4-7") > 0.0);
1472        assert_eq!(tap.usage(), report.usage);
1473        assert!(tap.was_sent());
1474        assert_eq!(mock.bodies.lock().unwrap().len(), 1, "no transport retry");
1475    }
1476
1477    /// The ping shares the session's silent-downgrade latch (not a fresh
1478    /// one): a downgrade it observes is the session's one notice.
1479    #[test]
1480    fn ping_shares_the_session_ttl_latch() {
1481        let rt = opus(CacheTtl::OneHour);
1482        assert!(!rt.cache_ttl_downgraded());
1483        let mut ping_rt = rt.clone();
1484        ping_rt.share_ttl_latches(&rt);
1485        ping_rt
1486            .ttl_downgrade_notified
1487            .store(true, Ordering::Relaxed);
1488        assert!(rt.cache_ttl_downgraded(), "the latch is shared");
1489    }
1490
1491    #[test]
1492    fn failure_classification() {
1493        let api = || RuntimeError::ApiStatus("x".into());
1494        assert_eq!(
1495            classify_failure(&RuntimeError::Canceled, 0),
1496            PingFailure::Canceled
1497        );
1498        assert!(matches!(
1499            classify_failure(&api(), 400),
1500            PingFailure::Fatal(_)
1501        ));
1502        assert!(matches!(
1503            classify_failure(&api(), 401),
1504            PingFailure::Fatal(_)
1505        ));
1506        assert!(matches!(
1507            classify_failure(&api(), 429),
1508            PingFailure::Transient(_)
1509        ));
1510        assert!(matches!(
1511            classify_failure(&api(), 529),
1512            PingFailure::Transient(_)
1513        ));
1514        assert!(matches!(
1515            classify_failure(&api(), 0),
1516            PingFailure::Transient(_)
1517        ));
1518        // An in-stream error under a 200 is transient.
1519        assert!(matches!(
1520            classify_failure(&api(), 200),
1521            PingFailure::Transient(_)
1522        ));
1523        assert!(matches!(
1524            classify_failure(&RuntimeError::Config("x".into()), 0),
1525            PingFailure::Fatal(_)
1526        ));
1527    }
1528
1529    #[test]
1530    fn every_ttl_and_thinking_mode_is_eligible() {
1531        for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour, CacheTtl::Hybrid] {
1532            for (model, level) in [
1533                ("claude-opus-4-7", ReasoningLevel::High),
1534                ("claude-sonnet-4-6", ReasoningLevel::High),
1535                ("claude-sonnet-4-6", ReasoningLevel::Off),
1536            ] {
1537                let mut rt = Runtime::new_headless();
1538                rt.set_model(model.to_string());
1539                rt.set_reasoning_level_explicit(level);
1540                rt.set_cache_ttl(ttl);
1541                assert_eq!(
1542                    rt.cache_keepwarm_ineligibility(),
1543                    None,
1544                    "{model} {level:?} {ttl:?}"
1545                );
1546            }
1547        }
1548    }
1549
1550    #[test]
1551    fn non_anthropic_models_are_ineligible() {
1552        let mut rt = Runtime::new_headless();
1553        rt.set_model("groq/llama-3.3-70b-versatile".to_string());
1554        if rt.model() == "groq/llama-3.3-70b-versatile" {
1555            assert!(rt.cache_keepwarm_ineligibility().is_some());
1556        }
1557    }
1558
1559    /// An ineligible ping (non-Anthropic model) fails closed, fatally, before
1560    /// any network I/O.
1561    #[tokio::test]
1562    async fn ineligible_ping_sends_nothing() {
1563        let mut rt = Runtime::new_headless();
1564        rt.set_model("groq/llama-3.3-70b-versatile".to_string());
1565        if rt.model() != "groq/llama-3.3-70b-versatile" {
1566            return;
1567        }
1568        let history = vec![
1569            user("hello"),
1570            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1571        ];
1572        let tap = PingTap::default();
1573        let err = rt
1574            .cache_keepwarm_ping(&history, 0, &CancellationToken::new(), &tap)
1575            .await
1576            .result
1577            .expect_err("non-Anthropic must refuse");
1578        assert!(!tap.was_sent());
1579        assert!(
1580            matches!(err, PingFailure::Fatal(ref m) if m.contains("keep-warm skipped")),
1581            "{err:?}"
1582        );
1583    }
1584
1585    #[test]
1586    fn anchor_cell_only_moves_forward() {
1587        let cell = CacheAnchorCell::default();
1588        assert_eq!(cell.get(), None);
1589        let a = CacheAnchor::now();
1590        let later = CacheAnchor {
1591            mono: a.mono + std::time::Duration::from_secs(5),
1592            wall: a.wall + std::time::Duration::from_secs(5),
1593        };
1594        cell.stamp(later);
1595        cell.stamp(a);
1596        assert_eq!(
1597            cell.get(),
1598            Some(later),
1599            "a late-arriving older stamp never rewinds"
1600        );
1601    }
1602
1603    #[test]
1604    fn only_a_finished_assistant_text_reply_is_markable() {
1605        assert!(tail_is_markable(
1606            &json!({"role": "assistant", "content": [{"type": "thinking", "thinking": "t"}, {"type": "text", "text": "ok"}]})
1607        ));
1608        assert!(!tail_is_markable(&json!({"role": "user", "content": "q"})));
1609        assert!(!tail_is_markable(
1610            &json!({"role": "assistant", "content": [{"type": "tool_use", "id": "t", "name": "x", "input": {}}]})
1611        ));
1612        assert!(!tail_is_markable(
1613            &json!({"role": "assistant", "content": [{"type": "thinking", "thinking": "t"}]})
1614        ));
1615    }
1616
1617    #[test]
1618    fn usage_is_summed_and_priced() {
1619        let mut u = KeepWarmUsage::default();
1620        u.add(&StreamEvent::Session(SessionEvent::Usage {
1621            input_tokens: 20,
1622            output_tokens: 16,
1623            cache_read_input_tokens: 500_000,
1624            cache_creation_input_tokens: 0,
1625            cache_creation_5m: None,
1626            cache_creation_1h: Some(0),
1627            model: Some("claude-opus-4-7".into()),
1628        }));
1629        assert_eq!(u.cache_read_tokens, 500_000);
1630        assert_eq!(u.output_tokens, 16);
1631        // 500k reads at $0.50/MTok + 16 output at $25/MTok + 20 input at $5/MTok.
1632        let cost = u.cost_usd("claude-opus-4-7");
1633        assert!((cost - 0.2505).abs() < 0.001, "{cost}");
1634    }
1635
1636    /// The one booking rule (I-2), as a table.
1637    #[test]
1638    fn booking_rule_table() {
1639        use PingBooking::*;
1640        // (usage arrived, sent, connect failed, HTTP status) → booking
1641        let table = [
1642            ((true, true, false, 200), Measured),
1643            ((true, false, false, 0), Measured),
1644            ((true, true, false, 529), Measured),
1645            ((false, true, false, 200), WorstCase), // aborted mid-stream
1646            ((false, true, false, 0), WorstCase),   // read timeout after dispatch
1647            ((false, true, false, 500), Nothing),   // rejected by status: not billed
1648            ((false, true, false, 429), Nothing),
1649            ((false, true, false, 529), Nothing),
1650            ((false, true, true, 0), Nothing), // connect failure, no status
1651            ((false, true, true, 503), Nothing), // an error status, no usage: not billed
1652            ((false, false, false, 0), Nothing), // refused before the network
1653            ((false, false, true, 0), Nothing),
1654        ];
1655        for ((usage, sent, connect, status), want) in table {
1656            assert_eq!(
1657                ping_booking(usage, sent, connect, status),
1658                want,
1659                "usage={usage} sent={sent} connect_failed={connect} status={status}"
1660            );
1661        }
1662        // And the bill prices each booking.
1663        let tap = PingTap::default();
1664        assert_eq!(tap.bill("claude-opus-4-7", 0.5).cost_usd, 0.0);
1665        tap.sent.store(true, Ordering::Relaxed);
1666        let bill = tap.bill("claude-opus-4-7", 0.5);
1667        assert_eq!((bill.booking, bill.cost_usd), (WorstCase, 0.5));
1668        tap.usage.lock().unwrap().cache_read_tokens = 1_000_000;
1669        let bill = tap.bill("claude-opus-4-7", 0.5);
1670        assert_eq!(bill.booking, Measured);
1671        assert!((bill.cost_usd - 0.5).abs() < 1e-9, "1M reads at $0.50/MTok");
1672    }
1673
1674    fn two_message_history() -> Vec<SharedMessage> {
1675        vec![
1676            user("hello"),
1677            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1678        ]
1679    }
1680
1681    /// A refused connection never marks the ping sent: it is booked at $0.
1682    #[tokio::test]
1683    #[serial_test::serial(synaps_base_dir)]
1684    async fn a_refused_connection_is_never_sent_and_books_nothing() {
1685        let _base = crate::test_env::BaseDirGuard::new();
1686        let addr = {
1687            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1688            l.local_addr().unwrap()
1689        }; // dropped: nothing listens there now
1690        let _url = crate::test_env::EnvVarGuard::set(
1691            "SYNAPS_ANTHROPIC_BASE_URL",
1692            &format!("http://{addr}"),
1693        );
1694        let history = two_message_history();
1695        let rt = opus(CacheTtl::OneHour);
1696        let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1697        let tap = PingTap::default();
1698        let report = rt
1699            .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &tap)
1700            .await;
1701        assert!(report.result.is_err(), "{report:?}");
1702        assert!(!tap.was_sent(), "never dispatched");
1703        assert!(tap.connect_failed.load(Ordering::Relaxed));
1704        let bill = tap.bill("claude-opus-4-7", 0.75);
1705        assert_eq!((bill.booking, bill.cost_usd), (PingBooking::Nothing, 0.0));
1706    }
1707
1708    /// The body went out, the provider never answered (read timeout / the
1709    /// actor aborted it): no usage, but it may be billed — worst case.
1710    #[tokio::test]
1711    #[serial_test::serial(synaps_base_dir)]
1712    async fn a_dispatched_ping_without_usage_books_the_worst_case() {
1713        use tokio::io::AsyncReadExt;
1714        let _base = crate::test_env::BaseDirGuard::new();
1715        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1716        let addr = listener.local_addr().unwrap();
1717        // Accept, swallow the request, never respond.
1718        tokio::spawn(async move {
1719            let (mut sock, _) = listener.accept().await.unwrap();
1720            let mut buf = vec![0u8; 64 * 1024];
1721            while sock.read(&mut buf).await.is_ok_and(|n| n > 0) {}
1722        });
1723        let _url = crate::test_env::EnvVarGuard::set(
1724            "SYNAPS_ANTHROPIC_BASE_URL",
1725            &format!("http://{addr}"),
1726        );
1727        let history = two_message_history();
1728        let rt = opus(CacheTtl::OneHour);
1729        let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1730        let tap = PingTap::default();
1731        let cancel = CancellationToken::new();
1732        let ping = {
1733            let (rt, history, tap, cancel) =
1734                (rt.clone(), history.clone(), tap.clone(), cancel.clone());
1735            tokio::spawn(async move { rt.cache_keepwarm_ping(&history, fp, &cancel, &tap).await })
1736        };
1737        tokio::time::timeout(std::time::Duration::from_secs(10), async {
1738            while !tap.was_sent() {
1739                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1740            }
1741        })
1742        .await
1743        .expect("the body was pulled over the established connection");
1744        cancel.cancel();
1745        let report = ping.await.unwrap();
1746        assert_eq!(report.result, Err(PingFailure::Canceled));
1747        assert!(!tap.connect_failed.load(Ordering::Relaxed));
1748        let bill = tap.bill("claude-opus-4-7", 0.75);
1749        assert_eq!(
1750            (bill.booking, bill.cost_usd),
1751            (PingBooking::WorstCase, 0.75)
1752        );
1753    }
1754
1755    #[test]
1756    fn reply_prefix_tokens_skip_thinking() {
1757        let text = "a".repeat(250); // 100 tokens at 2.5 chars/token
1758        let with_thinking = vec![Arc::new(json!({"role": "assistant", "content": [
1759            {"type": "thinking", "thinking": "x".repeat(50_000), "signature": "s"},
1760            {"type": "text", "text": text},
1761        ]}))];
1762        let plain = vec![Arc::new(json!({"role": "assistant", "content": [
1763            {"type": "text", "text": text},
1764        ]}))];
1765        let t = reply_prefix_tokens(&with_thinking);
1766        assert_eq!(t, reply_prefix_tokens(&plain), "thinking is not counted");
1767        assert!((100..200).contains(&t), "{t}");
1768        assert_eq!(reply_prefix_tokens(&[]), 0);
1769    }
1770}