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.is_none_or(|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) && !super::api::is_retryable_status(status) {
329        return PingFailure::Fatal(format!("HTTP {status}"));
330    }
331    match err {
332        // `NetworkTimeout` (upload/header deadline, stream idle) is a network
333        // blip like the `Api(timeout)` it replaced: back off, don't end the period.
334        RuntimeError::Api(_)
335        | RuntimeError::ApiStatus(_)
336        | RuntimeError::Timeout
337        | RuntimeError::NetworkTimeout(_) => PingFailure::Transient(if status == 0 {
338            "network".to_string()
339        } else {
340            format!("HTTP {status}")
341        }),
342        other => PingFailure::Fatal(other.to_string()),
343    }
344}
345
346/// The marker moves onto the last history message, so it must be a block the
347/// API accepts `cache_control` on: a finished assistant reply ending in a
348/// non-empty text block (never a thinking block, never a dangling tool_use).
349fn tail_is_markable(last: &Value) -> bool {
350    if last["role"] != "assistant" {
351        return false;
352    }
353    match last["content"].as_array() {
354        Some(blocks) => blocks.last().is_some_and(|b| {
355            b["type"] == "text" && b["text"].as_str().is_some_and(|t| !t.is_empty())
356        }),
357        None => last["content"].as_str().is_some_and(|t| !t.is_empty()),
358    }
359}
360
361/// Whether `history` can be kept warm: it must end with a finished assistant
362/// reply (a turn that errored or was interrupted leaves nothing worth pinging).
363pub fn history_ready(history: &[SharedMessage]) -> bool {
364    history.last().is_some_and(|m| tail_is_markable(m))
365}
366
367/// Conservative token estimate of the part of the history's last message
368/// (the turn's final reply) that the cached prefix will hold: its content
369/// minus thinking blocks, which the API strips when the next request
370/// re-sends the reply (they were billed as output, never cached). Ping 1
371/// may write at most this (plus the last request's uncached input) without
372/// counting as a partial miss.
373pub fn reply_prefix_tokens(history: &[SharedMessage]) -> u64 {
374    let Some(last) = history.last() else {
375        return 0;
376    };
377    let kept = match last["content"].as_array() {
378        Some(blocks) => Value::Array(
379            blocks
380                .iter()
381                .filter(|b| !matches!(b["type"].as_str(), Some("thinking" | "redacted_thinking")))
382                .cloned()
383                .collect(),
384        ),
385        None => last["content"].clone(),
386    };
387    super::context::conservative_token_estimate(&kept.to_string())
388}
389
390/// Run one ping's request to completion, draining its private event channel
391/// WHILE it runs: usage lands in the tap as it arrives, so an aborted ping
392/// can still be booked. Notices (and retry notes) are collected; with
393/// `forward`, the ping's `Usage` and `Notice` events are also passed on as
394/// they arrive (a tool-round ping books into its turn).
395pub(super) async fn drive_ping<F>(
396    call: F,
397    rx: &mut mpsc::UnboundedReceiver<StreamEvent>,
398    tap: &PingTap,
399    forward: Option<&mpsc::UnboundedSender<StreamEvent>>,
400) -> (Result<Value>, Vec<String>)
401where
402    F: std::future::Future<Output = Result<Value>>,
403{
404    tokio::pin!(call);
405    let mut notices = Vec::new();
406    let mut on_event = |event: StreamEvent| {
407        if let StreamEvent::Session(SessionEvent::Notice(n) | SessionEvent::Retry(n)) = &event {
408            notices.push(n.clone());
409        }
410        tap.usage
411            .lock()
412            .unwrap_or_else(std::sync::PoisonError::into_inner)
413            .add(&event);
414        if let Some(tx) = forward {
415            if matches!(
416                event,
417                StreamEvent::Session(SessionEvent::Usage { .. } | SessionEvent::Notice(_))
418            ) {
419                let _ = tx.send(event);
420            }
421        }
422    };
423    let result = loop {
424        tokio::select! {
425            r = &mut call => break r,
426            Some(event) = rx.recv() => on_event(event),
427        }
428    };
429    while let Ok(event) = rx.try_recv() {
430        on_event(event);
431    }
432    (result, notices)
433}
434
435impl Runtime {
436    /// Why this runtime cannot keep its cache warm, or `None` when it can.
437    /// Only the native Anthropic transport carries `cache_control`; every
438    /// TTL (5m, 1h, hybrid) and every thinking mode is supported.
439    pub fn cache_keepwarm_ineligibility(&self) -> Option<String> {
440        if !super::auth::model_is_anthropic(&self.model) {
441            return Some(format!(
442                "model {} is not on the native Anthropic transport",
443                self.model
444            ));
445        }
446        None
447    }
448
449    /// The last accepted Anthropic request of this runtime's request chain.
450    pub fn cache_anchor(&self) -> Option<CacheAnchor> {
451        self.cache_anchor.get()
452    }
453
454    /// The session's silent 1h→5m downgrade latch has fired: the server
455    /// honours only 5m whatever the configured TTL says.
456    pub fn cache_ttl_downgraded(&self) -> bool {
457        self.ttl_downgrade_notified.load(Ordering::Relaxed)
458    }
459
460    /// A request of this session has written a 1h cache entry.
461    pub fn cache_1h_honoured(&self) -> bool {
462        self.saw_1h_honored.load(Ordering::Relaxed)
463    }
464
465    /// Probe the ping the next real turn would justify for `history` (see
466    /// the module docs): its prefix fingerprint, its worst-case output, and
467    /// whether the next turn would roll the context over first. Builds the
468    /// request through the real builder and returns before anything is
469    /// sent; no network I/O.
470    pub async fn cache_keepwarm_probe(&self, history: &[SharedMessage]) -> Result<KeepWarmProbe> {
471        let cancel = CancellationToken::new();
472        let tap = PingTap::default();
473        let (value, _) = self
474            .keepwarm_request(history, &cancel, KeepWarmMode::Probe, &tap)
475            .await;
476        let value = value?;
477        let fingerprint = value["keepwarm_prefix"].as_u64().ok_or_else(|| {
478            RuntimeError::Session("keep-warm probe produced no fingerprint".into())
479        })?;
480        Ok(KeepWarmProbe {
481            fingerprint,
482            max_tokens: value["keepwarm_max_tokens"].as_u64().unwrap_or(0),
483            context_blocks: value["keepwarm_context_blocks"]
484                .as_str()
485                .map(str::to_string),
486        })
487    }
488
489    /// Send ONE keep-warm ping for `history` (the session's durable history,
490    /// unchanged) if its freshly built prefix still hashes to `expect`.
491    /// Never mutates `history`, the runtime's session state, or any persisted
492    /// state; fires no extension hooks; emits nothing to any frontend.
493    /// `tap` sees the request leave and its usage arrive while it runs.
494    pub async fn cache_keepwarm_ping(
495        &self,
496        history: &[SharedMessage],
497        expect: u64,
498        cancel: &CancellationToken,
499        tap: &PingTap,
500    ) -> PingReport {
501        let (value, notices) = self
502            .keepwarm_request(history, cancel, KeepWarmMode::Send { expect }, tap)
503            .await;
504        let result = match value {
505            Ok(value) => match value.get("keepwarm_prefix").and_then(Value::as_u64) {
506                Some(actual) => Ok(PingOutcome::PrefixChanged {
507                    expected: expect,
508                    actual,
509                }),
510                None => Ok(PingOutcome::Sent),
511            },
512            Err(e) => Err(classify_failure(&e, tap.status.load(Ordering::Relaxed))),
513        };
514        PingReport {
515            result,
516            usage: tap.usage(),
517            notices,
518        }
519    }
520
521    async fn keepwarm_request(
522        &self,
523        history: &[SharedMessage],
524        cancel: &CancellationToken,
525        mode: KeepWarmMode,
526        tap: &PingTap,
527    ) -> (Result<Value>, Vec<String>) {
528        match self
529            .keepwarm_request_inner(history, cancel, mode, tap)
530            .await
531        {
532            Ok(r) => r,
533            Err(e) => (Err(e), Vec::new()),
534        }
535    }
536
537    /// `Err` = refused before any request was built (nothing billed);
538    /// `Ok((Err, _))` = the request itself failed (usage, if any, is in `tap`).
539    async fn keepwarm_request_inner(
540        &self,
541        history: &[SharedMessage],
542        cancel: &CancellationToken,
543        mode: KeepWarmMode,
544        tap: &PingTap,
545    ) -> Result<(Result<Value>, Vec<String>)> {
546        if let Some(reason) = self.cache_keepwarm_ineligibility() {
547            return Err(RuntimeError::Config(format!(
548                "cache keep-warm skipped: {reason}"
549            )));
550        }
551        if !history_ready(history) {
552            return Err(RuntimeError::Config(
553                "cache keep-warm skipped: history does not end with a finished assistant reply"
554                    .into(),
555            ));
556        }
557        let (durability_blocked, context_enabled) = {
558            let state = self
559                .continuation
560                .lock()
561                .unwrap_or_else(std::sync::PoisonError::into_inner);
562            (state.durability_blocked, state.enabled())
563        };
564        if durability_blocked {
565            return Err(RuntimeError::Session(
566                "cache keep-warm skipped: context head save is unresolved".into(),
567            ));
568        }
569
570        // Same preflight as a real turn (run_stream_tracked). A probe sends
571        // nothing, so it never needs a token refresh.
572        self.validate_request_preflight().await?;
573        let anthropic_execution_plan = self.authorized_anthropic_plan().await?;
574        if matches!(mode, KeepWarmMode::Send { .. }) {
575            // Never selects, switches or re-binds an account; a cooling
576            // account ends the period (the ping fails before sending).
577            self.refresh_for_keepwarm().await?;
578        }
579
580        // System prompt: run_stream_tracked → run_stream_internal, verbatim.
581        let system_prompt = self.effective_system_prompt().await;
582        let system_prompt = if context_enabled {
583            Some(format!(
584                "{}\n\n{}",
585                system_prompt.as_deref().unwrap_or_default(),
586                super::continuation::GUIDANCE
587            ))
588        } else {
589            system_prompt
590        };
591        let session_injection = match self.session_id.as_deref() {
592            Some(id) => self.hook_bus.session_injection_for(id).await,
593            None => None,
594        };
595        let system_prompt = match session_injection {
596            Some(content) => Some(super::stream::wrap_extension_context(
597                system_prompt.as_deref().unwrap_or_default(),
598                &content,
599            )),
600            None => system_prompt,
601        };
602
603        // Messages: the durable history plus one request-local trailing user
604        // message. The same request-local image retention as a real round
605        // (`image_retention::round_images`), so the retired prefix, and the
606        // retention anchor the transport places from it, match the next turn.
607        let mut request: Vec<SharedMessage> = history.to_vec();
608        request.push(Arc::new(
609            json!({"role": "user", "content": KEEPWARM_PING_TEXT}),
610        ));
611        let round_images =
612            super::image_retention::round_images(&self.model, self.codex_request_role(), &request)
613                .map_err(RuntimeError::Config)?;
614        let (request_messages, _) = round_images.views(&request);
615
616        // Tools: a snapshot of the live registry, as this chat's next turn
617        // will be offered it (`chat_tools::chat_tool_set`: the same tools
618        // withheld), and projected exactly like a stream round when
619        // progressive disclosure is on — read-only: the retained session
620        // tool set is never written.
621        let tools_snapshot = self.tools.read().await.clone();
622        let (request_tools_schema, anthropic_tool_split) = if self.progressive_tool_disclosure {
623            let fresh = super::chat_tools::chat_tool_set(
624                self.host_tool_session.clone(),
625                tools_snapshot.catalog(),
626                true,
627                context_enabled,
628                &self.tools_always_loaded,
629            );
630            // The SAME selection a turn start makes (`retain_session_tool_set`),
631            // through its pure half: nothing is written back.
632            let retained = self
633                .retained_tool_set
634                .lock()
635                .unwrap_or_else(std::sync::PoisonError::into_inner)
636                .clone();
637            let existing = retained.as_ref().map(|shared| {
638                shared
639                    .read()
640                    .unwrap_or_else(std::sync::PoisonError::into_inner)
641            });
642            let (mut set, _, _) = super::stream::select_turn_start_tool_set(
643                existing.as_deref(),
644                fresh,
645                tools_snapshot.catalog(),
646                true,
647            );
648            drop(existing);
649            // Activations carried from the previous runtime (reload, unpark)
650            // that the next turn start will restore: projected here too, on
651            // this local copy only, so a ping before that turn warms the
652            // prefix the turn will send.
653            let pending = self
654                .pending_tool_restore
655                .lock()
656                .unwrap_or_else(std::sync::PoisonError::into_inner)
657                .clone();
658            if let Some(carried) = pending {
659                if crate::tools::activation::carry_forward_enabled() {
660                    let _ = super::stream::restore_pending_activations(
661                        &mut set,
662                        &carried,
663                        tools_snapshot.catalog(),
664                        self.activation_confirm,
665                    );
666                }
667            }
668            let schema = tools_snapshot.session_tools_schema(&set).schema;
669            let split = self.deferred_tool_loading.then(|| {
670                let referenced = HelperMethods::referenced_tool_ids(request_messages);
671                Arc::new(tools_snapshot.deferred_tool_split(&set, &referenced))
672            });
673            (Some(Arc::new(schema)), split)
674        } else {
675            let withheld = super::chat_tools::withheld_tools(context_enabled);
676            (tools_snapshot.tools_schema_without(&withheld), None)
677        };
678
679        let options = ApiOptions {
680            // A ping runs no tool: nothing of the chat's goes with it.
681            tool_context: None,
682            use_1m_context: self.context_window_override == Some(1_000_000),
683            cache_ttl: self.cache_ttl,
684            // The SESSION's latch: a ping that sees the silent 1h→5m
685            // downgrade raises the one notice (the actor surfaces it) and
686            // the actor schedules by the 5m TTL the server honours.
687            ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
688            saw_1h_honored: self.saw_1h_honored.clone(),
689            credential_source: self.credential_source.clone(),
690            token_cache: self.token_cache.clone(),
691            anthropic_base_url: None,
692            anthropic_stream_client: self.anthropic_stream_client.clone(),
693            request_compression: self.request_compression.clone(),
694            anthropic_execution_plan,
695            codex_request_role: self.codex_request_role(),
696            // The turns' own `thinking` object: the ping's prefix is theirs.
697            thinking_display: self.wire_thinking_display(),
698            retention_frontier: round_images.applied_frontier(),
699            // The base context: a ping must never consume `/trace next`.
700            trace: self.trace_ctx.clone(),
701            request_correlation: None,
702            suppress_stream_deltas: true,
703            telemetry: self.request_telemetry(),
704            tool_session_id: Some(self.host_tool_session.clone()),
705            session_tool_set: None,
706            request_tools_schema: request_tools_schema.clone(),
707            anthropic_tool_split,
708            usage_counters: None,
709            // A successful ping refreshes the cache: it moves the anchor the
710            // next ping is scheduled from.
711            cache_anchor: Some(Arc::clone(&self.cache_anchor)),
712            keepwarm: Some(KeepWarmRequest {
713                mode,
714                status: Arc::clone(&tap.status),
715                sent: Arc::clone(&tap.sent),
716                connect_failed: Arc::clone(&tap.connect_failed),
717            }),
718            // Header only (cache-key parity with the turns this ping keeps
719            // warm); a ping never sends `diagnostics` nor moves the chain.
720            cache_diagnosis: self.cache_diagnosis(),
721            context_rollover: None,
722            round_signal: None,
723        };
724
725        let (tx, mut rx) = mpsc::unbounded_channel();
726        let call = ApiMethods::call_api_stream_inner(
727            &self.auth,
728            &self.client,
729            &self.model,
730            &tools_snapshot,
731            &system_prompt,
732            self.thinking_budget,
733            self.reasoning_level(),
734            request_messages,
735            tx,
736            cancel,
737            0,
738            0,
739            &options,
740            self.telemetry_level,
741        );
742        let (mut result, notices) = drive_ping(call, &mut rx, tap, None).await;
743        // Probe: also run the next turn's pre-request context assessment.
744        if let (Ok(value), KeepWarmMode::Probe) = (&mut result, mode) {
745            if context_enabled {
746                if let Some(why) = self.keepwarm_context_blocks(
747                    history,
748                    system_prompt.as_deref(),
749                    request_tools_schema.as_deref().map(|s| s.as_slice()),
750                    &tools_snapshot,
751                ) {
752                    value["keepwarm_context_blocks"] = Value::String(why);
753                }
754            }
755        }
756        Ok((result, notices))
757    }
758
759    /// The pre-request context assessment of `run_stream_internal`, for the
760    /// next turn = `history` + an empty user message, WITHOUT committing the
761    /// policy's next state. `Some(reason)` on Rollover / HardStop.
762    ///
763    /// Mirrors the real round exactly: it assesses the round's final
764    /// `request_messages` — after the request-local history image cap — via
765    /// the same [`super::stream::pre_request_context_budget`].
766    fn keepwarm_context_blocks(
767        &self,
768        history: &[SharedMessage],
769        system_prompt: Option<&str>,
770        projected_schema: Option<&[Value]>,
771        tools_snapshot: &crate::ToolRegistry,
772    ) -> Option<String> {
773        use agent_core::core::context_policy::{assess_context, ContextAction};
774        let mut next: Vec<SharedMessage> = history.to_vec();
775        next.push(Arc::new(json!({"role": "user", "content": ""})));
776        // Estimated exactly like a real round: over the retention view.
777        let round_images =
778            super::image_retention::round_images(&self.model, self.codex_request_role(), &next)
779                .ok();
780        let (_, request_messages) = match &round_images {
781            Some(round) => round.views(&next),
782            None => (&next[..], &next[..]),
783        };
784        let fallback_schema;
785        let schema = match projected_schema {
786            Some(s) => s,
787            None => {
788                fallback_schema = tools_snapshot.tools_schema();
789                fallback_schema.as_slice()
790            }
791        };
792        let anchor = self
793            .continuation
794            .lock()
795            .unwrap_or_else(std::sync::PoisonError::into_inner)
796            .usage_anchor_for(&self.model)
797            .cloned();
798        let (assessment, budget) = super::stream::pre_request_context_budget(
799            &self.model,
800            self.context_window(),
801            system_prompt,
802            schema,
803            request_messages,
804            self.thinking_budget as u64,
805            self.max_tool_output as u64,
806            anchor.as_ref(),
807        );
808        let state = self
809            .continuation
810            .lock()
811            .unwrap_or_else(std::sync::PoisonError::into_inner);
812        let decision = assess_context(&state.config, &state.policy, budget);
813        match decision.action {
814            ContextAction::Rollover => Some(format!(
815                "the next turn would roll the context over ({} tokens used)",
816                assessment.used_tokens()
817            )),
818            ContextAction::HardStop => Some(format!(
819                "the next turn would hit the context hard limit ({} tokens used)",
820                assessment.used_tokens()
821            )),
822            _ => None,
823        }
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use crate::core::config::CacheTtl;
831    use crate::extensions::hooks::events::{HookEvent, HookKind, HookResult};
832    use crate::extensions::permissions::{Permission, PermissionSet};
833    use agent_core::reasoning::ReasoningLevel;
834    use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Router};
835    use futures::StreamExt;
836    use std::sync::atomic::AtomicUsize;
837    use std::sync::Mutex;
838
839    const SSE_HI: &str = concat!(
840        "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_kw\",\"type\":\"message\",",
841        "\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-4-7\",\"stop_reason\":null,",
842        "\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0,",
843        "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}}\n\n",
844        "data: {\"type\":\"content_block_start\",\"index\":0,",
845        "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
846        "data: {\"type\":\"content_block_delta\",\"index\":0,",
847        "\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n",
848        "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
849        "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",",
850        "\"stop_sequence\":null},\"usage\":{\"input_tokens\":10,\"output_tokens\":1,",
851        "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}\n\n",
852        "data: {\"type\":\"message_stop\"}\n\n",
853    );
854
855    /// A 200 stream that reports usage, then fails in-stream (overloaded).
856    const SSE_USAGE_THEN_ERROR: &str = concat!(
857        "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_kw\",\"type\":\"message\",",
858        "\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-4-7\",\"stop_reason\":null,",
859        "\"stop_sequence\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":0,",
860        "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5000}}}\n\n",
861        "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":null,",
862        "\"stop_sequence\":null},\"usage\":{\"input_tokens\":12,\"output_tokens\":1,",
863        "\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5000}}\n\n",
864        "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",",
865        "\"message\":\"synthetic\"}}\n\n",
866    );
867
868    #[derive(Clone)]
869    struct Mock {
870        bodies: Arc<Mutex<Vec<String>>>,
871        /// HTTP status served (200 = `sse`).
872        status: u16,
873        sse: &'static str,
874    }
875
876    async fn spawn_mock() -> (String, Mock) {
877        spawn_mock_full(200, SSE_HI).await
878    }
879
880    async fn spawn_mock_with(status: u16) -> (String, Mock) {
881        spawn_mock_full(status, SSE_HI).await
882    }
883
884    async fn spawn_mock_full(status: u16, sse: &'static str) -> (String, Mock) {
885        let mock = Mock {
886            bodies: Arc::default(),
887            status,
888            sse,
889        };
890        let app = Router::new()
891            .route(
892                "/v1/messages",
893                post(|State(m): State<Mock>, body: String| async move {
894                    m.bodies.lock().unwrap().push(body);
895                    if m.status == 200 {
896                        (
897                            StatusCode::OK,
898                            [("content-type", "text/event-stream")],
899                            m.sse.to_string(),
900                        )
901                            .into_response()
902                    } else {
903                        (
904                            StatusCode::from_u16(m.status).unwrap(),
905                            [("content-type", "application/json")],
906                            r#"{"type":"error","error":{"type":"api_error","message":"synthetic"}}"#
907                                .to_string(),
908                        )
909                            .into_response()
910                    }
911                }),
912            )
913            .layer(axum::extract::DefaultBodyLimit::max(64 * 1024 * 1024))
914            .with_state(mock.clone());
915        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
916        let addr = listener.local_addr().unwrap();
917        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
918        (format!("http://{addr}"), mock)
919    }
920
921    /// `before_message` hook that counts its calls and injects per-turn text.
922    struct CountingInject(Arc<AtomicUsize>);
923    #[async_trait::async_trait]
924    impl crate::extensions::runtime::ExtensionHandler for CountingInject {
925        fn id(&self) -> &str {
926            "keepwarm-counting-inject"
927        }
928        async fn handle(&self, _event: &HookEvent) -> HookResult {
929            self.0.fetch_add(1, Ordering::SeqCst);
930            HookResult::Inject {
931                content: "PER-TURN-INJECTED-CONTEXT".into(),
932            }
933        }
934        async fn shutdown(&self) {}
935    }
936
937    /// Run one real turn and return its final durable history.
938    async fn real_turn(rt: &Runtime, messages: Vec<SharedMessage>) -> Vec<SharedMessage> {
939        let mut stream = rt
940            .run_stream_with_messages(messages, CancellationToken::new(), None, None, false)
941            .await;
942        let mut history = None;
943        while let Some(event) = stream.next().await {
944            match event {
945                StreamEvent::Session(SessionEvent::MessageHistory(h)) => history = Some(h),
946                StreamEvent::Session(SessionEvent::Error(e)) => panic!("turn failed: {e:?}"),
947                _ => {}
948            }
949        }
950        history.expect("turn published its history")
951    }
952
953    fn user(text: &str) -> SharedMessage {
954        Arc::new(json!({"role": "user", "content": text}))
955    }
956
957    fn canon(v: &Value) -> String {
958        serde_json::to_string(v).unwrap()
959    }
960
961    fn strip_cache_control(mut msg: Value) -> Value {
962        if let Some(blocks) = msg["content"].as_array_mut() {
963            for b in blocks {
964                if let Some(obj) = b.as_object_mut() {
965                    obj.remove("cache_control");
966                }
967            }
968        }
969        msg
970    }
971
972    fn marker_count(v: &Value) -> usize {
973        match v {
974            Value::Object(map) => {
975                usize::from(map.contains_key("cache_control"))
976                    + map.values().map(marker_count).sum::<usize>()
977            }
978            Value::Array(items) => items.iter().map(marker_count).sum(),
979            _ => 0,
980        }
981    }
982
983    /// What [`ping_then_next_turn`] observed.
984    struct Observed {
985        ping: Value,
986        real: Value,
987        raw_ping: String,
988        history_len: usize,
989        usage: KeepWarmUsage,
990        /// `before_message` calls before the ping, after it, after the next turn.
991        hook_calls: (usize, usize, usize),
992    }
993
994    /// Previous turn → probe → keep-warm ping → the real next turn, on one
995    /// runtime. Also proves: the probe sends nothing and its fingerprint is
996    /// the one the ping accepts; the ping does not touch history; accepted
997    /// requests move the cache anchor.
998    async fn ping_then_next_turn(rt: &Runtime) -> Observed {
999        let (url, mock) = spawn_mock().await;
1000        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1001        let calls = Arc::new(AtomicUsize::new(0));
1002        let mut perms = PermissionSet::new();
1003        perms.grant(Permission::LlmContent);
1004        rt.hook_bus()
1005            .subscribe(
1006                HookKind::BeforeMessage,
1007                Arc::new(CountingInject(Arc::clone(&calls))),
1008                None,
1009                None,
1010                perms,
1011            )
1012            .await
1013            .unwrap();
1014
1015        assert_eq!(rt.cache_anchor(), None, "no request has run yet");
1016        // The previous turn: the session is idle afterwards.
1017        let history = real_turn(rt, vec![user("hello")]).await;
1018        assert_eq!(history.last().unwrap()["role"], "assistant");
1019        let turn_anchor = rt
1020            .cache_anchor()
1021            .expect("an accepted turn request stamps the anchor");
1022        let before_ping = calls.load(Ordering::SeqCst);
1023        let snapshot = history.clone();
1024
1025        let fingerprint = rt
1026            .cache_keepwarm_probe(&history)
1027            .await
1028            .expect("probe")
1029            .fingerprint;
1030        assert_eq!(
1031            mock.bodies.lock().unwrap().len(),
1032            1,
1033            "a probe sends nothing"
1034        );
1035        assert_eq!(
1036            rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1037            fingerprint,
1038            "the fingerprint is deterministic"
1039        );
1040
1041        let tap = PingTap::default();
1042        let report = rt
1043            .cache_keepwarm_ping(&history, fingerprint, &CancellationToken::new(), &tap)
1044            .await;
1045        assert_eq!(report.result, Ok(PingOutcome::Sent), "{report:?}");
1046        assert!(tap.was_sent(), "the tap saw the request leave");
1047        assert_eq!(tap.usage(), report.usage, "the tap saw the usage");
1048        let usage = report.usage;
1049        assert!(
1050            usage.output_tokens >= 1,
1051            "usage comes back from the private channel"
1052        );
1053        let ping_anchor = rt.cache_anchor().expect("anchor");
1054        assert!(
1055            ping_anchor.mono >= turn_anchor.mono,
1056            "the ping moved the anchor"
1057        );
1058        let after_ping = calls.load(Ordering::SeqCst);
1059        assert_eq!(history, snapshot, "the ping must not mutate history");
1060
1061        // The real next turn.
1062        let mut next = history.clone();
1063        next.push(user("next question"));
1064        real_turn(rt, next).await;
1065        let after_next = calls.load(Ordering::SeqCst);
1066
1067        let bodies = mock.bodies.lock().unwrap().clone();
1068        assert_eq!(bodies.len(), 3, "previous turn, ping, next turn");
1069        Observed {
1070            ping: serde_json::from_str(&bodies[1]).unwrap(),
1071            real: serde_json::from_str(&bodies[2]).unwrap(),
1072            raw_ping: bodies[1].clone(),
1073            history_len: history.len(),
1074            usage,
1075            hook_calls: (before_ping, after_ping, after_next),
1076        }
1077    }
1078
1079    fn assert_prefix_identical(
1080        ping: &Value,
1081        real: &Value,
1082        n: usize,
1083        history_marker: &Value,
1084        ping_max_tokens: u64,
1085    ) {
1086        // Everything the cache key covers besides messages.
1087        for key in [
1088            "model",
1089            "tools",
1090            "system",
1091            "thinking",
1092            "output_config",
1093            "stream",
1094        ] {
1095            assert_eq!(
1096                canon(&ping[key]),
1097                canon(&real[key]),
1098                "`{key}` differs between ping and the real next turn"
1099            );
1100        }
1101        let pm = ping["messages"].as_array().unwrap();
1102        let rm = real["messages"].as_array().unwrap();
1103        assert_eq!(pm.len(), n + 1, "history + one trailing ping message");
1104        assert!(rm.len() > n);
1105        // History before the last message: byte-identical as sent.
1106        for i in 0..n - 1 {
1107            assert_eq!(canon(&pm[i]), canon(&rm[i]), "message {i} differs");
1108        }
1109        // The last history message: identical except the ping's marker,
1110        // which carries the history TTL (the governing one).
1111        let last = &pm[n - 1];
1112        let marker = &last["content"].as_array().unwrap().last().unwrap()["cache_control"];
1113        assert_eq!(marker, history_marker);
1114        assert_eq!(
1115            canon(&strip_cache_control(last.clone())),
1116            canon(&rm[n - 1]),
1117            "last history message differs beyond the cache marker"
1118        );
1119        // The trailing ping message sits AFTER the last breakpoint.
1120        assert_eq!(marker_count(&pm[n]), 0, "ping message must be unmarked");
1121        assert_eq!(pm[n]["role"], "user");
1122        assert_eq!(pm[n]["content"], KEEPWARM_PING_TEXT);
1123        // Output is cut; the real turn keeps its own max_tokens.
1124        assert_eq!(ping["max_tokens"], json!(ping_max_tokens));
1125        assert!(real["max_tokens"].as_u64().unwrap() > ping_max_tokens);
1126        assert!(
1127            marker_count(ping) <= 4,
1128            "Anthropic allows at most 4 breakpoints"
1129        );
1130    }
1131
1132    fn marker_1h() -> Value {
1133        json!({"type": "ephemeral", "ttl": "1h"})
1134    }
1135
1136    fn marker_5m() -> Value {
1137        json!({"type": "ephemeral"})
1138    }
1139
1140    fn opus(ttl: CacheTtl) -> Runtime {
1141        let mut rt = Runtime::new_headless();
1142        rt.set_model("claude-opus-4-7".to_string());
1143        assert_eq!(rt.model(), "claude-opus-4-7");
1144        rt.set_reasoning_level_explicit(ReasoningLevel::High);
1145        rt.set_cache_ttl(ttl);
1146        rt.set_system_prompt("You are a keep-warm test.".into());
1147        assert_eq!(rt.cache_keepwarm_ineligibility(), None);
1148        rt
1149    }
1150
1151    /// Adaptive thinking (Opus 4.7), 1h TTL: same thinking + effort, prefix
1152    /// through the last marker matches the real next turn, no hook ran for
1153    /// the ping, output capped at one token.
1154    #[tokio::test]
1155    #[serial_test::serial(synaps_base_dir)]
1156    async fn ping_prefix_matches_next_turn_with_adaptive_thinking() {
1157        let _base = crate::test_env::BaseDirGuard::new();
1158        let rt = opus(CacheTtl::OneHour);
1159
1160        let o = ping_then_next_turn(&rt).await;
1161        assert!(
1162            !o.ping["thinking"].is_null(),
1163            "thinking must stay on: {}",
1164            o.ping
1165        );
1166        assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_1h(), 1);
1167
1168        // Hooks: live for real turns, never for the ping.
1169        let (before, after_ping, after_next) = o.hook_calls;
1170        assert!(
1171            before >= 1,
1172            "control: the previous turn fired before_message"
1173        );
1174        assert_eq!(after_ping, before, "the ping fired a hook");
1175        assert!(
1176            after_next > after_ping,
1177            "control: the next turn fired before_message"
1178        );
1179        assert!(!o.raw_ping.contains("PER-TURN-INJECTED-CONTEXT"));
1180        assert!(!o.raw_ping.contains("Extension context"));
1181        // Control: the real next turn DID carry the injection — in its tail.
1182        assert!(canon(&o.real).contains("PER-TURN-INJECTED-CONTEXT"));
1183    }
1184
1185    /// 5m TTL is first-class: same identity, bare ephemeral marker.
1186    #[tokio::test]
1187    #[serial_test::serial(synaps_base_dir)]
1188    async fn ping_prefix_matches_next_turn_with_5m_ttl() {
1189        let _base = crate::test_env::BaseDirGuard::new();
1190        let rt = opus(CacheTtl::FiveMinutes);
1191        let o = ping_then_next_turn(&rt).await;
1192        assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_5m(), 1);
1193    }
1194
1195    /// Hybrid: the history marker is the 5m one (it governs the interval);
1196    /// tools/system keep their 1h markers byte-identically.
1197    #[tokio::test]
1198    #[serial_test::serial(synaps_base_dir)]
1199    async fn ping_prefix_matches_next_turn_with_hybrid_ttl() {
1200        let _base = crate::test_env::BaseDirGuard::new();
1201        let rt = opus(CacheTtl::Hybrid);
1202        let o = ping_then_next_turn(&rt).await;
1203        assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_5m(), 1);
1204        assert!(
1205            canon(&o.ping["system"]).contains("\"ttl\":\"1h\""),
1206            "hybrid keeps the 1h stable-prefix marker: {}",
1207            o.ping["system"]
1208        );
1209    }
1210
1211    /// Thinking off on a budgeted-thinking model.
1212    #[tokio::test]
1213    #[serial_test::serial(synaps_base_dir)]
1214    async fn ping_prefix_matches_next_turn_with_thinking_off() {
1215        let _base = crate::test_env::BaseDirGuard::new();
1216        let mut rt = Runtime::new_headless();
1217        rt.set_model("claude-sonnet-4-6".to_string());
1218        rt.set_reasoning_level_explicit(ReasoningLevel::Off);
1219        rt.set_cache_ttl(CacheTtl::OneHour);
1220        assert_eq!(rt.cache_keepwarm_ineligibility(), None);
1221
1222        let o = ping_then_next_turn(&rt).await;
1223        assert!(o.ping.get("thinking").is_none(), "thinking off is omitted");
1224        assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_1h(), 1);
1225    }
1226
1227    /// Budgeted thinking (Sonnet 4.6, `type: enabled`) is SUPPORTED: the
1228    /// budget is part of the cache key and stays; `max_tokens` (not part of
1229    /// the key) is `budget + 1`, the API's minimum; the ping is costed from
1230    /// its measured usage. Under 5m TTL here.
1231    #[tokio::test]
1232    #[serial_test::serial(synaps_base_dir)]
1233    async fn budgeted_thinking_ping_keeps_budget_and_is_costed() {
1234        let _base = crate::test_env::BaseDirGuard::new();
1235        let mut rt = Runtime::new_headless();
1236        rt.set_model("claude-sonnet-4-6".to_string());
1237        rt.set_reasoning_level_explicit(ReasoningLevel::High);
1238        rt.set_cache_ttl(CacheTtl::FiveMinutes);
1239        assert_eq!(
1240            rt.cache_keepwarm_ineligibility(),
1241            None,
1242            "budgeted thinking is eligible"
1243        );
1244
1245        let o = ping_then_next_turn(&rt).await;
1246        assert_eq!(o.ping["thinking"]["type"], "enabled", "{}", o.ping);
1247        let budget = o.ping["thinking"]["budget_tokens"]
1248            .as_u64()
1249            .expect("budget");
1250        assert_eq!(canon(&o.ping["thinking"]), canon(&o.real["thinking"]));
1251        assert_prefix_identical(
1252            &o.ping,
1253            &o.real,
1254            o.history_len,
1255            &marker_5m(),
1256            budget + KEEPWARM_OUTPUT_TOKENS,
1257        );
1258        assert!(
1259            o.usage.cost_usd("claude-sonnet-4-6") > 0.0,
1260            "the ping is costed"
1261        );
1262    }
1263
1264    /// A changed prefix is never sent: model, thinking level, system prompt
1265    /// and history changes all flip the fingerprint, the ping reports
1266    /// `PrefixChanged`, and the provider sees no request.
1267    #[tokio::test]
1268    #[serial_test::serial(synaps_base_dir)]
1269    async fn changed_prefix_is_refused_before_the_network() {
1270        let _base = crate::test_env::BaseDirGuard::new();
1271        let (url, mock) = spawn_mock().await;
1272        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1273        let mut rt = opus(CacheTtl::FiveMinutes);
1274        let history = real_turn(&rt, vec![user("hello")]).await;
1275        let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1276        let sent = mock.bodies.lock().unwrap().len();
1277
1278        // History grew (e.g. a compaction / /clear / a new turn).
1279        let mut longer = history.clone();
1280        longer.push(user("more"));
1281        longer.push(Arc::new(
1282            json!({"role": "assistant", "content": [{"type": "text", "text": "x"}]}),
1283        ));
1284        let changed = |r: PingReport| matches!(r.result, Ok(PingOutcome::PrefixChanged { .. }));
1285        let tap = PingTap::default();
1286        assert!(changed(
1287            rt.cache_keepwarm_ping(&longer, fp, &CancellationToken::new(), &tap)
1288                .await
1289        ));
1290
1291        rt.set_system_prompt("A different system prompt.".into());
1292        assert_ne!(
1293            rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1294            fp
1295        );
1296        assert!(changed(
1297            rt.cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &tap)
1298                .await
1299        ));
1300        rt.set_system_prompt("You are a keep-warm test.".into());
1301        assert_eq!(
1302            rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1303            fp,
1304            "restored"
1305        );
1306
1307        rt.set_reasoning_level_explicit(ReasoningLevel::Low);
1308        assert_ne!(
1309            rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1310            fp,
1311            "effort is in the key"
1312        );
1313        rt.set_reasoning_level_explicit(ReasoningLevel::High);
1314
1315        rt.set_model("claude-opus-4-6".to_string());
1316        assert_eq!(rt.model(), "claude-opus-4-6");
1317        assert_ne!(
1318            rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint,
1319            fp,
1320            "model is in the key"
1321        );
1322
1323        assert_eq!(
1324            mock.bodies.lock().unwrap().len(),
1325            sent,
1326            "no refused ping reached the provider"
1327        );
1328        assert!(!tap.was_sent(), "a refused ping never leaves");
1329    }
1330
1331    #[tokio::test]
1332    #[serial_test::serial(synaps_base_dir)]
1333    async fn http_4xx_is_fatal_and_5xx_is_transient() {
1334        let _base = crate::test_env::BaseDirGuard::new();
1335        let history = vec![
1336            user("hello"),
1337            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1338        ];
1339        for (status, fatal) in [(400u16, true), (404, true), (500, false), (429, false)] {
1340            let (url, mock) = spawn_mock_with(status).await;
1341            let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1342            let rt = opus(CacheTtl::FiveMinutes);
1343            let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1344            let err = rt
1345                .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &PingTap::default())
1346                .await
1347                .result
1348                .expect_err("non-2xx fails");
1349            assert_eq!(
1350                matches!(err, PingFailure::Fatal(_)),
1351                fatal,
1352                "HTTP {status}: {err:?}"
1353            );
1354            assert_eq!(
1355                mock.bodies.lock().unwrap().len(),
1356                1,
1357                "HTTP {status}: no retry"
1358            );
1359            assert_eq!(
1360                rt.cache_anchor(),
1361                None,
1362                "a rejected request refreshes nothing"
1363            );
1364        }
1365    }
1366
1367    /// The prefix-identity proof for one more request shape: the ping and
1368    /// the real next turn send identical model/tools/system/thinking/effort
1369    /// and history through the marker.
1370    async fn assert_identity_1h(rt: &Runtime) -> Observed {
1371        let o = ping_then_next_turn(rt).await;
1372        assert_prefix_identical(&o.ping, &o.real, o.history_len, &marker_1h(), 1);
1373        o
1374    }
1375
1376    /// Progressive tool disclosure: the tool schema is the projection of the
1377    /// retained session tool set, selected exactly as a turn start selects
1378    /// it (`select_turn_start_tool_set`).
1379    #[tokio::test]
1380    #[serial_test::serial(synaps_base_dir)]
1381    async fn ping_prefix_matches_next_turn_with_progressive_disclosure() {
1382        let _base = crate::test_env::BaseDirGuard::new();
1383        let mut rt = opus(CacheTtl::OneHour);
1384        rt.progressive_tool_disclosure = true;
1385        assert_identity_1h(&rt).await;
1386    }
1387
1388    /// Deferred tool loading on top of progressive disclosure: the
1389    /// loaded/deferred split is part of the identity.
1390    #[tokio::test]
1391    #[serial_test::serial(synaps_base_dir)]
1392    async fn ping_prefix_matches_next_turn_with_deferred_tools() {
1393        let _base = crate::test_env::BaseDirGuard::new();
1394        let mut rt = opus(CacheTtl::OneHour);
1395        rt.progressive_tool_disclosure = true;
1396        rt.deferred_tool_loading = true;
1397        assert_identity_1h(&rt).await;
1398    }
1399
1400    /// A ping projects exactly what the next turn start selects — including
1401    /// activations handed over by a daemon reload or an unpark that the turn
1402    /// start has not restored yet. (The session only schedules pings after
1403    /// an accepted request of the same runtime, so this is the projection
1404    /// invariant, not a reachable pre-turn ping.)
1405    #[tokio::test]
1406    #[serial_test::serial(synaps_base_dir)]
1407    async fn ping_projects_carried_activations_the_turn_start_restores() {
1408        use crate::tools::activation::{activate_exact_for_user, SessionId, SessionToolSet};
1409        use crate::tools::catalog::ToolId;
1410        let _base = crate::test_env::BaseDirGuard::new();
1411        let (url, mock) = spawn_mock().await;
1412        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1413        let mut rt = opus(CacheTtl::OneHour);
1414        rt.progressive_tool_disclosure = true;
1415        rt.deferred_tool_loading = true;
1416        let carried = {
1417            let registry = rt.tools.read().await;
1418            let mut previous = SessionToolSet::progressive_core_for_catalog(
1419                SessionId::parse("previous-runtime").unwrap(),
1420                registry.catalog(),
1421            );
1422            activate_exact_for_user(
1423                &mut previous,
1424                registry.catalog(),
1425                &ToolId::builtin("subagent_status"),
1426            )
1427            .unwrap();
1428            previous.carried_activations()
1429        };
1430        rt.restore_tool_activations(carried);
1431
1432        let history = vec![
1433            user("hello"),
1434            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1435        ];
1436        let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1437        let report = rt
1438            .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &PingTap::default())
1439            .await;
1440        assert_eq!(report.result, Ok(PingOutcome::Sent), "{report:?}");
1441        let mut next = history.clone();
1442        next.push(user("next"));
1443        real_turn(&rt, next).await;
1444
1445        let bodies: Vec<Value> = mock
1446            .bodies
1447            .lock()
1448            .unwrap()
1449            .iter()
1450            .map(|b| serde_json::from_str(b).unwrap())
1451            .collect();
1452        let (ping, real) = (&bodies[0], &bodies[1]);
1453        // The non-deferred tools are the cached prefix. (The deferred list
1454        // can still differ here: this runtime's first turn applies its
1455        // idempotent setup; deferred definitions are outside the prefix.)
1456        let loaded = |b: &Value| -> Vec<Value> {
1457            b["tools"]
1458                .as_array()
1459                .unwrap()
1460                .iter()
1461                .filter(|t| t.get("defer_loading").is_none())
1462                .cloned()
1463                .collect()
1464        };
1465        assert_eq!(
1466            serde_json::to_string(&loaded(ping)).unwrap(),
1467            serde_json::to_string(&loaded(real)).unwrap()
1468        );
1469        assert!(
1470            real["tools"].as_array().unwrap().iter().any(|t| {
1471                t["name"] == "subagent_status" && t.get("defer_loading").is_none()
1472            }),
1473            "the restored activation is a loaded tool: {}",
1474            real["tools"]
1475        );
1476    }
1477
1478    /// Context management on: the guidance is appended to the system prompt
1479    /// of both.
1480    #[tokio::test]
1481    #[serial_test::serial(synaps_base_dir)]
1482    async fn ping_prefix_matches_next_turn_with_context_management_guidance() {
1483        let _base = crate::test_env::BaseDirGuard::new();
1484        let rt = opus(CacheTtl::OneHour);
1485        rt.continuation.lock().unwrap().config.mode =
1486            crate::core::config::ContextManagementMode::Auto;
1487        let o = assert_identity_1h(&rt).await;
1488        assert!(
1489            canon(&o.ping["system"]).contains("Work normally."),
1490            "guidance present: {}",
1491            o.ping["system"]
1492        );
1493    }
1494
1495    /// A session-scoped extension injection rides in the system prompt of
1496    /// both (unlike the per-turn `before_message` injection, which is tail).
1497    #[tokio::test]
1498    #[serial_test::serial(synaps_base_dir)]
1499    async fn ping_prefix_matches_next_turn_with_session_injection() {
1500        let _base = crate::test_env::BaseDirGuard::new();
1501        let mut rt = opus(CacheTtl::OneHour);
1502        rt.set_session_id(Some("kw-session".into()));
1503        rt.hook_bus()
1504            .set_session_injection_for("kw-session", "SESSION-SCOPED-INJECTION".into())
1505            .await;
1506        let o = assert_identity_1h(&rt).await;
1507        assert!(canon(&o.ping["system"]).contains("SESSION-SCOPED-INJECTION"));
1508    }
1509
1510    /// A live OAuth (subscription) credential on `rt`, bound to the default
1511    /// local seat so the turn's binding check takes the fast path instead of
1512    /// re-resolving from auth.json.
1513    fn bind_oauth(rt: &mut Runtime) {
1514        rt.auth = Arc::new(tokio::sync::RwLock::new(super::super::types::AuthState {
1515            auth_token: "oauth-test-token".to_string(),
1516            auth_type: "oauth".to_string(),
1517            refresh_token: None,
1518            token_expires: Some(crate::epoch_millis() + 3_600_000),
1519            auth_file_mtime: None,
1520            bound_credential: Some(crate::runtime::auth::anthropic_binding(
1521                &crate::auth::CredentialSource::Local,
1522                &crate::auth::CredentialRef::default_for(crate::auth::OAuthProviderId::Anthropic),
1523            )),
1524        }));
1525    }
1526
1527    /// Review fix 2: an idle keep-warm ping never selects, switches or
1528    /// re-binds an account. The session's account cooling down (a limit met
1529    /// elsewhere) stops it before anything is sent, saying so; the session
1530    /// stays bound to its account.
1531    #[tokio::test]
1532    #[serial_test::serial(synaps_base_dir)]
1533    async fn an_idle_ping_on_a_cooling_account_sends_nothing() {
1534        let _base = crate::test_env::BaseDirGuard::new();
1535        let (url, mock) = spawn_mock().await;
1536        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1537        let mut rt = opus(CacheTtl::OneHour);
1538        bind_oauth(&mut rt);
1539        let history = real_turn(&rt, vec![user("hello")]).await;
1540        let fingerprint = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1541        let seat = crate::auth::CredentialRef::default_for(crate::auth::OAuthProviderId::Anthropic);
1542        crate::auth::broker_from_source(&rt.credential_source, &rt.token_cache, reqwest::Client::new())
1543            .report_cooldown(&seat, Some(crate::epoch_millis() + 3_600_000), "test")
1544            .await
1545            .unwrap();
1546        let bound = rt.auth.read().await.bound_credential.clone();
1547
1548        let tap = PingTap::default();
1549        let report = rt
1550            .cache_keepwarm_ping(&history, fingerprint, &CancellationToken::new(), &tap)
1551            .await;
1552        match &report.result {
1553            Err(PingFailure::Fatal(why)) => assert!(why.contains("account cooling"), "{why}"),
1554            other => panic!("the ping stops on the cooling account: {other:?}"),
1555        }
1556        assert!(!tap.was_sent());
1557        assert!(report.notices.is_empty(), "{:?}", report.notices);
1558        assert_eq!(mock.bodies.lock().unwrap().len(), 1, "only the turn was sent");
1559        assert_eq!(rt.auth.read().await.bound_credential, bound, "not re-bound");
1560        assert_eq!(rt.auth.read().await.auth_token, "oauth-test-token");
1561    }
1562
1563    /// Every `cache_control` value in a request body, in document order.
1564    fn markers(v: &Value) -> Vec<Value> {
1565        match v {
1566            Value::Object(map) => map
1567                .iter()
1568                .flat_map(|(k, v)| {
1569                    if k == "cache_control" {
1570                        vec![v.clone()]
1571                    } else {
1572                        markers(v)
1573                    }
1574                })
1575                .collect(),
1576            Value::Array(items) => items.iter().flat_map(markers).collect(),
1577            _ => Vec::new(),
1578        }
1579    }
1580
1581    /// `cache_ttl = auto` on the real request path (turn, ping, next turn),
1582    /// per credential kind: OAuth sends 1h on every marker, an API key the
1583    /// bare 5m marker, and explicit values win over the kind both ways. For
1584    /// `auto`, every body is byte-identical to what the explicit resolved
1585    /// value sends: `auto` adds nothing to the wire.
1586    #[tokio::test]
1587    #[serial_test::serial(synaps_base_dir)]
1588    async fn auto_ttl_wire_markers_follow_the_credential_kind() {
1589        use crate::core::config::CacheTtlSetting;
1590        let cases = [
1591            (CacheTtlSetting::Auto, true, CacheTtl::OneHour),
1592            (CacheTtlSetting::Auto, false, CacheTtl::FiveMinutes),
1593            (CacheTtl::FiveMinutes.into(), true, CacheTtl::FiveMinutes),
1594            (CacheTtl::OneHour.into(), false, CacheTtl::OneHour),
1595        ];
1596        for (setting, oauth, expect) in cases {
1597            let ctx = format!("{setting} on {}", if oauth { "oauth" } else { "api_key" });
1598            let observe = |setting: CacheTtlSetting| async move {
1599                let _base = crate::test_env::BaseDirGuard::new();
1600                let mut rt = opus(CacheTtl::FiveMinutes);
1601                rt.set_cache_ttl(setting);
1602                if oauth {
1603                    bind_oauth(&mut rt);
1604                }
1605                let resolved = rt.resolved_cache_ttl().await;
1606                (resolved, ping_then_next_turn(&rt).await)
1607            };
1608            let (resolved, o) = observe(setting).await;
1609            assert_eq!(resolved, expect, "{ctx}");
1610            let want = if expect == CacheTtl::OneHour {
1611                marker_1h()
1612            } else {
1613                marker_5m()
1614            };
1615            assert_prefix_identical(&o.ping, &o.real, o.history_len, &want, 1);
1616            for (name, body) in [("ping", &o.ping), ("next turn", &o.real)] {
1617                let all = markers(body);
1618                assert!(!all.is_empty(), "{ctx}: {name} has no markers");
1619                assert!(
1620                    all.iter().all(|m| *m == want),
1621                    "{ctx}: {name} markers {all:?}, want all {want}"
1622                );
1623            }
1624            if setting == CacheTtlSetting::Auto {
1625                let (_, fixed) = observe(expect.into()).await;
1626                assert_eq!(canon(&o.ping), canon(&fixed.ping), "{ctx}: ping bytes");
1627                assert_eq!(canon(&o.real), canon(&fixed.real), "{ctx}: turn bytes");
1628            }
1629        }
1630    }
1631
1632    /// OAuth: the Claude Code transport identity and the extra system blocks
1633    /// are identical on both.
1634    #[tokio::test]
1635    #[serial_test::serial(synaps_base_dir)]
1636    async fn ping_prefix_matches_next_turn_with_oauth_system_blocks() {
1637        let _base = crate::test_env::BaseDirGuard::new();
1638        let mut rt = opus(CacheTtl::OneHour);
1639        bind_oauth(&mut rt);
1640        let o = assert_identity_1h(&rt).await;
1641        assert!(
1642            canon(&o.ping["system"]).contains("You are Claude Code"),
1643            "{}",
1644            o.ping["system"]
1645        );
1646    }
1647
1648    /// Context rollover ahead: the probe runs the next turn's pre-request
1649    /// assessment (history + an empty user message) and reports a Rollover /
1650    /// HardStop without committing any policy state.
1651    #[tokio::test]
1652    #[serial_test::serial(synaps_base_dir)]
1653    async fn probe_reports_a_context_rollover_ahead() {
1654        let _base = crate::test_env::BaseDirGuard::new();
1655        let (url, _mock) = spawn_mock().await;
1656        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1657        let rt = opus(CacheTtl::OneHour);
1658        rt.continuation.lock().unwrap().config.mode =
1659            crate::core::config::ContextManagementMode::Auto;
1660        let history = real_turn(&rt, vec![user("hello")]).await;
1661        let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1662        assert_eq!(probe.context_blocks, None, "plenty of room");
1663        let before = rt.continuation.lock().unwrap().policy;
1664        // The next round needs more headroom than the window has left.
1665        rt.continuation.lock().unwrap().config.reserve_tokens = 10_000_000;
1666        let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1667        assert!(probe.context_blocks.is_some(), "{probe:?}");
1668        assert_eq!(
1669            rt.continuation.lock().unwrap().policy,
1670            before,
1671            "the probe never commits policy state"
1672        );
1673        // Context management off: no assessment, like a real turn.
1674        rt.continuation.lock().unwrap().config.mode =
1675            crate::core::config::ContextManagementMode::Off;
1676        assert_eq!(
1677            rt.cache_keepwarm_probe(&history)
1678                .await
1679                .unwrap()
1680                .context_blocks,
1681            None
1682        );
1683    }
1684
1685    /// The probe shares the turn's context budget, so it gains the realistic
1686    /// output reserve too: an Opus session at ~60k of its 200k window is
1687    /// warmed. With the full 128k `max_tokens` reserved the next turn was at
1688    /// hard capacity from ~31.5k on and the probe refused every ping there.
1689    #[tokio::test]
1690    #[serial_test::serial(synaps_base_dir)]
1691    async fn probe_warms_an_opus_200k_session_past_the_old_hard_stop() {
1692        let _base = crate::test_env::BaseDirGuard::new();
1693        let (url, _mock) = spawn_mock().await;
1694        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1695        let rt = opus(CacheTtl::OneHour);
1696        assert_eq!(rt.context_window(), 200_000);
1697        rt.continuation.lock().unwrap().config.mode =
1698            crate::core::config::ContextManagementMode::Auto;
1699        let history = real_turn(&rt, vec![user(&"x".repeat(150_000))]).await;
1700        let used = rt.assess_context(&history).await.used_tokens();
1701        assert!((60_000..100_000).contains(&used), "{used}");
1702        let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1703        assert_eq!(probe.context_blocks, None, "{probe:?}");
1704    }
1705
1706    /// The probe reports the ping's real `max_tokens` (worst-case output).
1707    #[tokio::test]
1708    #[serial_test::serial(synaps_base_dir)]
1709    async fn probe_reports_max_tokens() {
1710        let _base = crate::test_env::BaseDirGuard::new();
1711        let history = vec![
1712            user("hello"),
1713            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1714        ];
1715        let rt = opus(CacheTtl::OneHour);
1716        assert_eq!(
1717            rt.cache_keepwarm_probe(&history).await.unwrap().max_tokens,
1718            1
1719        );
1720        let mut rt = Runtime::new_headless();
1721        rt.set_model("claude-sonnet-4-6".to_string());
1722        rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
1723        let probe = rt.cache_keepwarm_probe(&history).await.unwrap();
1724        let budget = u64::from(rt.thinking_budget);
1725        assert!(budget >= 16_384, "budgeted thinking at xhigh: {budget}");
1726        assert_eq!(
1727            probe.max_tokens,
1728            budget + KEEPWARM_OUTPUT_TOKENS,
1729            "{probe:?}"
1730        );
1731    }
1732
1733    /// Usage is returned on EVERY path: a ping that was billed and then
1734    /// failed in-stream still reports what it cost (and so does the tap).
1735    #[tokio::test]
1736    #[serial_test::serial(synaps_base_dir)]
1737    async fn ping_usage_is_returned_on_error() {
1738        let _base = crate::test_env::BaseDirGuard::new();
1739        let (url, mock) = spawn_mock_full(200, SSE_USAGE_THEN_ERROR).await;
1740        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1741        let history = vec![
1742            user("hello"),
1743            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1744        ];
1745        let rt = opus(CacheTtl::OneHour);
1746        let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
1747        let tap = PingTap::default();
1748        let report = rt
1749            .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &tap)
1750            .await;
1751        assert!(
1752            matches!(report.result, Err(PingFailure::Transient(_))),
1753            "{report:?}"
1754        );
1755        assert_eq!(report.usage.cache_read_tokens, 5_000, "{report:?}");
1756        assert!(report.usage.cost_usd("claude-opus-4-7") > 0.0);
1757        assert_eq!(tap.usage(), report.usage);
1758        assert!(tap.was_sent());
1759        assert_eq!(mock.bodies.lock().unwrap().len(), 1, "no transport retry");
1760    }
1761
1762    /// `cache_diagnostics = true`: a ping keeps the turn's beta set (cache-key
1763    /// parity — the probe and the ping agree on the fingerprint, so it is
1764    /// sent) but never sends `diagnostics` and never moves the turn chain.
1765    #[tokio::test]
1766    #[serial_test::serial(synaps_base_dir)]
1767    async fn ping_never_carries_or_moves_the_diagnostics_chain() {
1768        let _base = crate::test_env::BaseDirGuard::new();
1769        let (url, mock) = spawn_mock().await;
1770        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
1771        let history = vec![
1772            user("hello"),
1773            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1774        ];
1775        let mut rt = opus(CacheTtl::OneHour);
1776        rt.set_cache_diagnostics(true);
1777        rt.cache_diagnosis().unwrap().record("msg_turn");
1778        let mut ping_rt = rt.clone();
1779        ping_rt.share_ttl_latches(&rt);
1780        let fp = ping_rt
1781            .cache_keepwarm_probe(&history)
1782            .await
1783            .unwrap()
1784            .fingerprint;
1785        let report = ping_rt
1786            .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &PingTap::default())
1787            .await;
1788        assert!(report.result.is_ok(), "{report:?}");
1789        let bodies = mock.bodies.lock().unwrap().clone();
1790        assert_eq!(bodies.len(), 1);
1791        assert!(!bodies[0].contains("\"diagnostics\""), "{}", bodies[0]);
1792        assert_eq!(
1793            rt.cache_diagnosis()
1794                .unwrap()
1795                .previous_message_id()
1796                .as_deref(),
1797            Some("msg_turn"),
1798            "the ping's msg_kw never enters the chain"
1799        );
1800    }
1801
1802    /// Driver-turn and ping clones continue the session's chain; a plain
1803    /// clone (subagent-style) starts its own chain but shares the rejection
1804    /// latch; off means no chain at all.
1805    #[test]
1806    fn diagnostics_chain_is_shared_like_the_ttl_latches() {
1807        let mut rt = opus(CacheTtl::FiveMinutes);
1808        assert!(rt.cache_diagnosis().is_none(), "off: wire unchanged");
1809        rt.set_cache_diagnostics(true);
1810        rt.cache_diagnosis().unwrap().record("msg_a");
1811        let fresh = rt.clone();
1812        assert_eq!(fresh.cache_diagnosis().unwrap().previous_message_id(), None);
1813        let mut shared = rt.clone();
1814        shared.share_ttl_latches(&rt);
1815        shared.cache_diagnosis().unwrap().record("msg_b");
1816        shared.cache_diagnosis().unwrap().reject(400);
1817        let chain = rt.cache_diagnosis().unwrap();
1818        assert_eq!(chain.previous_message_id().as_deref(), Some("msg_b"));
1819        assert!(!chain.active(), "the rejection latch is session-wide");
1820        // Subagent-style clones keep their own chain but share the latch:
1821        // a rejecting API is paid for (and logged) once per session.
1822        let fresh_chain = fresh.cache_diagnosis().unwrap();
1823        assert!(!fresh_chain.active(), "latch shared across Clone");
1824        assert_eq!(fresh_chain.previous_message_id(), None);
1825        assert!(!rt.clone().cache_diagnosis().unwrap().active());
1826    }
1827
1828    /// The ping shares the session's silent-downgrade latch (not a fresh
1829    /// one): a downgrade it observes is the session's one notice.
1830    #[test]
1831    fn ping_shares_the_session_ttl_latch() {
1832        let rt = opus(CacheTtl::OneHour);
1833        assert!(!rt.cache_ttl_downgraded());
1834        let mut ping_rt = rt.clone();
1835        ping_rt.share_ttl_latches(&rt);
1836        ping_rt
1837            .ttl_downgrade_notified
1838            .store(true, Ordering::Relaxed);
1839        assert!(rt.cache_ttl_downgraded(), "the latch is shared");
1840    }
1841
1842    #[test]
1843    fn failure_classification() {
1844        let api = || RuntimeError::ApiStatus("x".into());
1845        assert_eq!(
1846            classify_failure(&RuntimeError::Canceled, 0),
1847            PingFailure::Canceled
1848        );
1849        assert!(matches!(
1850            classify_failure(&api(), 400),
1851            PingFailure::Fatal(_)
1852        ));
1853        assert!(matches!(
1854            classify_failure(&api(), 401),
1855            PingFailure::Fatal(_)
1856        ));
1857        assert!(matches!(
1858            classify_failure(&api(), 429),
1859            PingFailure::Transient(_)
1860        ));
1861        assert!(matches!(
1862            classify_failure(&api(), 529),
1863            PingFailure::Transient(_)
1864        ));
1865        // A request timeout and Cloudflare edge errors back off, too.
1866        for status in [408, 504, 520, 524] {
1867            assert!(
1868                matches!(classify_failure(&api(), status), PingFailure::Transient(_)),
1869                "HTTP {status}"
1870            );
1871        }
1872        assert!(matches!(
1873            classify_failure(&api(), 0),
1874            PingFailure::Transient(_)
1875        ));
1876        // An in-stream error under a 200 is transient.
1877        assert!(matches!(
1878            classify_failure(&api(), 200),
1879            PingFailure::Transient(_)
1880        ));
1881        assert!(matches!(
1882            classify_failure(&RuntimeError::Config("x".into()), 0),
1883            PingFailure::Fatal(_)
1884        ));
1885    }
1886
1887    /// 3f2be9a3 split upload/header and stream-idle timeouts into
1888    /// `NetworkTimeout`. A slow ping is a network blip: back off and retry,
1889    /// never end the keep-warm period.
1890    #[test]
1891    fn network_timeout_is_transient() {
1892        for msg in ["upload/header deadline", "stream idle"] {
1893            assert_eq!(
1894                classify_failure(&RuntimeError::NetworkTimeout(msg), 0),
1895                PingFailure::Transient("network".to_string()),
1896                "{msg}"
1897            );
1898        }
1899    }
1900
1901    #[test]
1902    fn every_ttl_and_thinking_mode_is_eligible() {
1903        for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour, CacheTtl::Hybrid] {
1904            for (model, level) in [
1905                ("claude-opus-4-7", ReasoningLevel::High),
1906                ("claude-sonnet-4-6", ReasoningLevel::High),
1907                ("claude-sonnet-4-6", ReasoningLevel::Off),
1908            ] {
1909                let mut rt = Runtime::new_headless();
1910                rt.set_model(model.to_string());
1911                rt.set_reasoning_level_explicit(level);
1912                rt.set_cache_ttl(ttl);
1913                assert_eq!(
1914                    rt.cache_keepwarm_ineligibility(),
1915                    None,
1916                    "{model} {level:?} {ttl:?}"
1917                );
1918            }
1919        }
1920    }
1921
1922    #[test]
1923    fn non_anthropic_models_are_ineligible() {
1924        let mut rt = Runtime::new_headless();
1925        rt.set_model("groq/llama-3.3-70b-versatile".to_string());
1926        if rt.model() == "groq/llama-3.3-70b-versatile" {
1927            assert!(rt.cache_keepwarm_ineligibility().is_some());
1928        }
1929    }
1930
1931    /// An ineligible ping (non-Anthropic model) fails closed, fatally, before
1932    /// any network I/O.
1933    #[tokio::test]
1934    async fn ineligible_ping_sends_nothing() {
1935        let mut rt = Runtime::new_headless();
1936        rt.set_model("groq/llama-3.3-70b-versatile".to_string());
1937        if rt.model() != "groq/llama-3.3-70b-versatile" {
1938            return;
1939        }
1940        let history = vec![
1941            user("hello"),
1942            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
1943        ];
1944        let tap = PingTap::default();
1945        let err = rt
1946            .cache_keepwarm_ping(&history, 0, &CancellationToken::new(), &tap)
1947            .await
1948            .result
1949            .expect_err("non-Anthropic must refuse");
1950        assert!(!tap.was_sent());
1951        assert!(
1952            matches!(err, PingFailure::Fatal(ref m) if m.contains("keep-warm skipped")),
1953            "{err:?}"
1954        );
1955    }
1956
1957    /// An extension's provider that counts the requests it is sent.
1958    struct CountingProvider(AtomicUsize);
1959    #[async_trait::async_trait]
1960    impl crate::extensions::runtime::ExtensionHandler for CountingProvider {
1961        fn id(&self) -> &str {
1962            "plug"
1963        }
1964        async fn handle(&self, _event: &HookEvent) -> HookResult {
1965            HookResult::Continue
1966        }
1967        async fn shutdown(&self) {}
1968        async fn provider_complete(
1969            &self,
1970            _params: crate::extensions::runtime::process::ProviderCompleteParams,
1971        ) -> std::result::Result<
1972            crate::extensions::runtime::process::ProviderCompleteResult,
1973            String,
1974        > {
1975            self.0.fetch_add(1, Ordering::SeqCst);
1976            Ok(crate::extensions::runtime::process::ProviderCompleteResult {
1977                content: vec![json!({"type": "text", "text": "hi"})],
1978                stop_reason: None,
1979                usage: None,
1980            })
1981        }
1982    }
1983
1984    /// A chat with a keep-warm period on a Claude model that is switched to
1985    /// a model an extension provides (`/model`): the period's next probe and
1986    /// ping are refused before anything is built, and the extension is sent
1987    /// nothing. (Its id has no `/`, so it used to count as Anthropic's: the
1988    /// probe, documented as "no network I/O", was a full request to the
1989    /// extension, with the chat's tools, for no chat.) The same chat on the
1990    /// Claude model probes as before, and no request reaches the extension
1991    /// then either.
1992    #[tokio::test]
1993    #[serial_test::serial(synaps_base_dir, extension_routing)]
1994    async fn a_chat_switched_to_an_extension_model_sends_the_extension_no_keepwarm_request() {
1995        use crate::extensions::manager::ExtensionManager;
1996        use crate::extensions::runtime::process::{
1997            RegisteredProviderModelSpec, RegisteredProviderSpec,
1998        };
1999        const EXTENSION_MODEL: &str = "plug:prov:model-x";
2000        let _base = crate::test_env::BaseDirGuard::new();
2001        let (url, mock) = spawn_mock().await;
2002        let _url = crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
2003        let mut rt = opus(CacheTtl::OneHour);
2004        let provider = Arc::new(CountingProvider(AtomicUsize::new(0)));
2005        let mut manager = ExtensionManager::new_with_tools(rt.hook_bus.clone(), rt.tools.clone());
2006        manager
2007            .register_provider_handler_for_tests(
2008                "plug",
2009                RegisteredProviderSpec {
2010                    id: "prov".into(),
2011                    display_name: "Counting".into(),
2012                    description: "counts its requests".into(),
2013                    models: vec![RegisteredProviderModelSpec {
2014                        id: "model-x".into(),
2015                        display_name: None,
2016                        capabilities: json!({"streaming": false, "tool_use": true}),
2017                        context_window: None,
2018                    }],
2019                    config_schema: None,
2020                },
2021                provider.clone(),
2022            )
2023            .unwrap();
2024        crate::runtime::openai::set_extension_manager_for_routing(Arc::new(
2025            tokio::sync::RwLock::new(manager),
2026        ));
2027        let history = vec![
2028            user("hello"),
2029            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
2030        ];
2031
2032        // On the Claude model: eligible, and a probe sends nothing anywhere.
2033        let fingerprint = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
2034        assert_eq!(provider.0.load(Ordering::SeqCst), 0);
2035        assert!(mock.bodies.lock().unwrap().is_empty());
2036
2037        rt.set_model(EXTENSION_MODEL.to_string());
2038        assert_eq!(rt.model(), EXTENSION_MODEL);
2039        let ineligible = rt.cache_keepwarm_ineligibility();
2040        let probe = rt.cache_keepwarm_probe(&history).await;
2041        let tap = PingTap::default();
2042        let ping = rt
2043            .cache_keepwarm_ping(&history, fingerprint, &CancellationToken::new(), &tap)
2044            .await
2045            .result;
2046        crate::runtime::openai::clear_extension_manager_for_routing();
2047
2048        assert_eq!(
2049            provider.0.load(Ordering::SeqCst),
2050            0,
2051            "the extension was sent a request for a probe or a ping"
2052        );
2053        assert!(mock.bodies.lock().unwrap().is_empty(), "nor was Anthropic");
2054        assert!(
2055            ineligible
2056                .as_deref()
2057                .is_some_and(|why| why.contains("not on the native Anthropic transport")),
2058            "{ineligible:?}"
2059        );
2060        let refused = probe.expect_err("no probe for an extension's model").to_string();
2061        assert!(refused.contains("keep-warm skipped"), "{refused}");
2062        assert!(
2063            matches!(ping, Err(PingFailure::Fatal(ref m)) if m.contains("keep-warm skipped")),
2064            "{ping:?}"
2065        );
2066        assert!(!tap.was_sent());
2067    }
2068
2069    #[test]
2070    fn anchor_cell_only_moves_forward() {
2071        let cell = CacheAnchorCell::default();
2072        assert_eq!(cell.get(), None);
2073        let a = CacheAnchor::now();
2074        let later = CacheAnchor {
2075            mono: a.mono + std::time::Duration::from_secs(5),
2076            wall: a.wall + std::time::Duration::from_secs(5),
2077        };
2078        cell.stamp(later);
2079        cell.stamp(a);
2080        assert_eq!(
2081            cell.get(),
2082            Some(later),
2083            "a late-arriving older stamp never rewinds"
2084        );
2085    }
2086
2087    #[test]
2088    fn only_a_finished_assistant_text_reply_is_markable() {
2089        assert!(tail_is_markable(
2090            &json!({"role": "assistant", "content": [{"type": "thinking", "thinking": "t"}, {"type": "text", "text": "ok"}]})
2091        ));
2092        assert!(!tail_is_markable(&json!({"role": "user", "content": "q"})));
2093        assert!(!tail_is_markable(
2094            &json!({"role": "assistant", "content": [{"type": "tool_use", "id": "t", "name": "x", "input": {}}]})
2095        ));
2096        assert!(!tail_is_markable(
2097            &json!({"role": "assistant", "content": [{"type": "thinking", "thinking": "t"}]})
2098        ));
2099    }
2100
2101    #[test]
2102    fn usage_is_summed_and_priced() {
2103        let mut u = KeepWarmUsage::default();
2104        u.add(&StreamEvent::Session(SessionEvent::Usage {
2105            input_tokens: 20,
2106            output_tokens: 16,
2107            cache_read_input_tokens: 500_000,
2108            cache_creation_input_tokens: 0,
2109            cache_creation_5m: None,
2110            cache_creation_1h: Some(0),
2111            model: Some("claude-opus-4-7".into()),
2112        }));
2113        assert_eq!(u.cache_read_tokens, 500_000);
2114        assert_eq!(u.output_tokens, 16);
2115        // 500k reads at $0.50/MTok + 16 output at $25/MTok + 20 input at $5/MTok.
2116        let cost = u.cost_usd("claude-opus-4-7");
2117        assert!((cost - 0.2505).abs() < 0.001, "{cost}");
2118    }
2119
2120    /// The one booking rule (I-2), as a table.
2121    #[test]
2122    fn booking_rule_table() {
2123        use PingBooking::*;
2124        // (usage arrived, sent, connect failed, HTTP status) → booking
2125        let table = [
2126            ((true, true, false, 200), Measured),
2127            ((true, false, false, 0), Measured),
2128            ((true, true, false, 529), Measured),
2129            ((false, true, false, 200), WorstCase), // aborted mid-stream
2130            ((false, true, false, 0), WorstCase),   // read timeout after dispatch
2131            ((false, true, false, 500), Nothing),   // rejected by status: not billed
2132            ((false, true, false, 429), Nothing),
2133            ((false, true, false, 529), Nothing),
2134            ((false, true, true, 0), Nothing), // connect failure, no status
2135            ((false, true, true, 503), Nothing), // an error status, no usage: not billed
2136            ((false, false, false, 0), Nothing), // refused before the network
2137            ((false, false, true, 0), Nothing),
2138        ];
2139        for ((usage, sent, connect, status), want) in table {
2140            assert_eq!(
2141                ping_booking(usage, sent, connect, status),
2142                want,
2143                "usage={usage} sent={sent} connect_failed={connect} status={status}"
2144            );
2145        }
2146        // And the bill prices each booking.
2147        let tap = PingTap::default();
2148        assert_eq!(tap.bill("claude-opus-4-7", 0.5).cost_usd, 0.0);
2149        tap.sent.store(true, Ordering::Relaxed);
2150        let bill = tap.bill("claude-opus-4-7", 0.5);
2151        assert_eq!((bill.booking, bill.cost_usd), (WorstCase, 0.5));
2152        tap.usage.lock().unwrap().cache_read_tokens = 1_000_000;
2153        let bill = tap.bill("claude-opus-4-7", 0.5);
2154        assert_eq!(bill.booking, Measured);
2155        assert!((bill.cost_usd - 0.5).abs() < 1e-9, "1M reads at $0.50/MTok");
2156    }
2157
2158    fn two_message_history() -> Vec<SharedMessage> {
2159        vec![
2160            user("hello"),
2161            Arc::new(json!({"role": "assistant", "content": [{"type": "text", "text": "hi"}]})),
2162        ]
2163    }
2164
2165    /// A refused connection never marks the ping sent: it is booked at $0.
2166    #[tokio::test]
2167    #[serial_test::serial(synaps_base_dir)]
2168    async fn a_refused_connection_is_never_sent_and_books_nothing() {
2169        let _base = crate::test_env::BaseDirGuard::new();
2170        let addr = {
2171            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2172            l.local_addr().unwrap()
2173        }; // dropped: nothing listens there now
2174        let _url = crate::test_env::EnvVarGuard::set(
2175            "SYNAPS_ANTHROPIC_BASE_URL",
2176            &format!("http://{addr}"),
2177        );
2178        let history = two_message_history();
2179        let rt = opus(CacheTtl::OneHour);
2180        let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
2181        let tap = PingTap::default();
2182        let report = rt
2183            .cache_keepwarm_ping(&history, fp, &CancellationToken::new(), &tap)
2184            .await;
2185        assert!(report.result.is_err(), "{report:?}");
2186        assert!(!tap.was_sent(), "never dispatched");
2187        assert!(tap.connect_failed.load(Ordering::Relaxed));
2188        let bill = tap.bill("claude-opus-4-7", 0.75);
2189        assert_eq!((bill.booking, bill.cost_usd), (PingBooking::Nothing, 0.0));
2190    }
2191
2192    /// The body went out, the provider never answered (read timeout / the
2193    /// actor aborted it): no usage, but it may be billed — worst case.
2194    #[tokio::test]
2195    #[serial_test::serial(synaps_base_dir)]
2196    async fn a_dispatched_ping_without_usage_books_the_worst_case() {
2197        use tokio::io::AsyncReadExt;
2198        let _base = crate::test_env::BaseDirGuard::new();
2199        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2200        let addr = listener.local_addr().unwrap();
2201        // Accept, swallow the request, never respond.
2202        tokio::spawn(async move {
2203            let (mut sock, _) = listener.accept().await.unwrap();
2204            let mut buf = vec![0u8; 64 * 1024];
2205            while sock.read(&mut buf).await.is_ok_and(|n| n > 0) {}
2206        });
2207        let _url = crate::test_env::EnvVarGuard::set(
2208            "SYNAPS_ANTHROPIC_BASE_URL",
2209            &format!("http://{addr}"),
2210        );
2211        let history = two_message_history();
2212        let rt = opus(CacheTtl::OneHour);
2213        let fp = rt.cache_keepwarm_probe(&history).await.unwrap().fingerprint;
2214        let tap = PingTap::default();
2215        let cancel = CancellationToken::new();
2216        let ping = {
2217            let (rt, history, tap, cancel) =
2218                (rt.clone(), history.clone(), tap.clone(), cancel.clone());
2219            tokio::spawn(async move { rt.cache_keepwarm_ping(&history, fp, &cancel, &tap).await })
2220        };
2221        tokio::time::timeout(std::time::Duration::from_secs(10), async {
2222            while !tap.was_sent() {
2223                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2224            }
2225        })
2226        .await
2227        .expect("the body was pulled over the established connection");
2228        cancel.cancel();
2229        let report = ping.await.unwrap();
2230        assert_eq!(report.result, Err(PingFailure::Canceled));
2231        assert!(!tap.connect_failed.load(Ordering::Relaxed));
2232        let bill = tap.bill("claude-opus-4-7", 0.75);
2233        assert_eq!(
2234            (bill.booking, bill.cost_usd),
2235            (PingBooking::WorstCase, 0.75)
2236        );
2237    }
2238
2239    #[test]
2240    fn reply_prefix_tokens_skip_thinking() {
2241        let text = "a".repeat(250); // 100 tokens at 2.5 chars/token
2242        let with_thinking = vec![Arc::new(json!({"role": "assistant", "content": [
2243            {"type": "thinking", "thinking": "x".repeat(50_000), "signature": "s"},
2244            {"type": "text", "text": text},
2245        ]}))];
2246        let plain = vec![Arc::new(json!({"role": "assistant", "content": [
2247            {"type": "text", "text": text},
2248        ]}))];
2249        let t = reply_prefix_tokens(&with_thinking);
2250        assert_eq!(t, reply_prefix_tokens(&plain), "thinking is not counted");
2251        assert!((100..200).contains(&t), "{t}");
2252        assert_eq!(reply_prefix_tokens(&[]), 0);
2253    }
2254}