Skip to main content

agent_engine/runtime/
context.rs

1//! Task 29 — centralized request-aware context budgeting (spec §9.1).
2//!
3//! The engine — not individual frontends — computes the effective context of
4//! the NEXT provider request from the segments that will actually be sent:
5//!
6//! - actual effective system segments;
7//! - exposed tool schemas;
8//! - conversation history plus protocol framing;
9//! - loaded skill and memory content;
10//!
11//! and carves typed reserves out of the provider window before dispatch:
12//!
13//! - reasoning/thinking reserve;
14//! - next likely tool-result reserve;
15//! - expected output reserve: what one round realistically produces
16//!   ([`EXPECTED_RESPONSE_TOKENS`] on top of its thinking), bounded by the
17//!   request's `max_tokens`. `max_tokens` is a ceiling, not headroom the
18//!   request needs (see [`ContextReserves::output_tokens`]);
19//! - a documented safety margin of [`SAFETY_MARGIN_PERCENT`] (spec requires
20//!   10–15%).
21//!
22//! Frontends consume [`ContextAssessment::should_compact`] — no per-frontend
23//! token math is allowed on the trigger path (enforced by the phase 5
24//! source-scan test).
25//!
26//! ## Estimation strategy
27//!
28//! Provider tokenizers plug in through [`estimator_for_model`] when they are
29//! available in-tree. None is today, so every model resolves to the
30//! CONSERVATIVE estimator, which must never overstate remaining capacity:
31//!
32//! - ASCII is charged at 1 token per 2.5 characters — the realistic BPE
33//!   worst case for high-entropy ASCII (hashes, base64, minified JSON), and
34//!   a deliberate ~1.6× overcount for English prose;
35//! - non-ASCII is charged at 1 token per UTF-8 byte — the byte-fallback
36//!   worst case, covering CJK (3 bytes/char), emoji scalars (4 bytes), and
37//!   two-byte scripts without ever understating.
38//!
39//! The estimator therefore errs toward EARLIER compaction, never toward
40//! provider exhaustion; the safety margin absorbs residual framing drift.
41
42use serde_json::Value;
43
44use crate::SharedMessage;
45
46/// Documented pre-dispatch safety margin, as a percentage of the provider
47/// window (spec §9.1: target at least 10–15% reserved capacity).
48pub const SAFETY_MARGIN_PERCENT: u64 = 15;
49
50/// Visible output (text and tool calls) one round is expected to need on
51/// top of its thinking. The output reserve is this plus the thinking reserve,
52/// capped at the request's `max_tokens` ([`ContextBudgetInputs::max_output_tokens`]).
53///
54/// Not the `max_tokens` ceiling itself (128k for Opus): Claude 4.5 and later
55/// accept a request whose input plus `max_tokens` exceeds the window and
56/// stop at the window with `stop_reason: "model_context_window_exceeded"`
57/// (<https://platform.claude.com/docs/en/build-with-claude/context-windows#context-window-overflow-behavior>),
58/// and for the earlier models that reject it the request builder clamps
59/// `max_tokens` to the room left (`HelperMethods::fit_max_tokens_to_window`).
60/// Reserving the full ceiling put hard capacity at ~31.5k on a 200k Opus
61/// window. 16k covers a large single-file write in one round; a round's
62/// output becomes the next round's input, so the following assessment sees
63/// it either way.
64pub const EXPECTED_RESPONSE_TOKENS: u64 = 16_384;
65
66/// Thinking reserve for a zero thinking budget: the runtime's adaptive
67/// ("the model decides") and off levels both carry 0, and the request builder
68/// itself sends a zero budget as this tier on fixed-budget models
69/// (`DEFAULT_LEGACY_ADAPTIVE_FALLBACK`, the "high" tier). Reserving it for off
70/// errs toward earlier rollover by at most this much.
71pub const ZERO_BUDGET_THINKING_RESERVE_TOKENS: u64 =
72    crate::core::models::DEFAULT_LEGACY_ADAPTIVE_FALLBACK as u64;
73
74/// Minimum history length before compaction is meaningful. A freshly
75/// compacted session (summary + acknowledgement) sits below this gate, which
76/// is what prevents recompaction loops without frontend-local bookkeeping.
77pub const MIN_COMPACTION_MESSAGES: usize = 4;
78
79/// Flat charge per base64 image block. Anthropic bills ≈ w·h/750 tokens
80/// AFTER its 1568-px / ~1.15 MP downscale, so 1,600 is the per-image
81/// ceiling (Goose uses the same figure; gemini-cli uses 3,000; Codex
82/// subtracts the payload and adds a ~7 KB estimate). Serialized base64 at
83/// 2.5 chars/token would charge ~1.9 M tokens for a 3.5 MiB image and trip
84/// compaction every turn.
85pub const IMAGE_TOKEN_ESTIMATE: u64 = 1_600;
86
87/// Provider-side per-message framing charge (role tags, message boundaries)
88/// on top of the serialized content itself.
89const PER_MESSAGE_FRAMING_TOKENS: u64 = 4;
90
91/// Tool-result bytes are converted to a token reserve at this many bytes per
92/// token — matching the ASCII charge rate of the conservative estimator, so
93/// the reserve and the usage accounting agree.
94const TOOL_RESULT_BYTES_PER_TOKEN: u64 = 3;
95
96/// Conservative token estimate for a text segment. See the module docs for
97/// the exact charging rule and why it never understates.
98pub fn conservative_token_estimate(text: &str) -> u64 {
99    // Scaled integer accumulation in fifths of a token: ASCII chars cost 2
100    // fifths (1 token per 2.5 chars); non-ASCII chars cost 5 fifths per
101    // UTF-8 byte (1 token per byte).
102    let mut fifths: u64 = 0;
103    for c in text.chars() {
104        if c.is_ascii() {
105            fifths += 2;
106        } else {
107            fifths += 5 * c.len_utf8() as u64;
108        }
109    }
110    fifths.div_ceil(5)
111}
112
113/// Token-estimation seam. Provider tokenizers register here per model when
114/// available; the conservative estimator is the mandatory fallback.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum TokenEstimator {
117    /// No exact tokenizer available — conservative charging (never
118    /// overstates remaining capacity).
119    Conservative,
120}
121
122impl TokenEstimator {
123    pub fn estimate(&self, text: &str) -> u64 {
124        match self {
125            TokenEstimator::Conservative => conservative_token_estimate(text),
126        }
127    }
128}
129
130/// Resolve the estimator for a model. No provider tokenizer crate is linked
131/// today, so every model — Anthropic, OpenAI, Google, unknown — uses the
132/// conservative estimator (spec §9.1: tokenizers where available,
133/// conservative estimators otherwise).
134pub fn estimator_for_model(_model: &str) -> TokenEstimator {
135    TokenEstimator::Conservative
136}
137
138/// Everything the budget calculation needs about the NEXT request. Callers
139/// pass the segments they will actually send; the engine never guesses.
140#[derive(Debug, Clone, Copy)]
141pub struct ContextBudgetInputs<'a> {
142    /// Model the request targets (selects the estimator).
143    pub model: &'a str,
144    /// Effective provider context window in tokens (override-aware).
145    pub provider_window: u64,
146    /// The actual effective system prompt, if any.
147    pub system_prompt: Option<&'a str>,
148    /// The EXPOSED tool schema set for this request (post-projection).
149    pub tools_schema: &'a [Value],
150    /// Conversation history exactly as it will be sent.
151    pub messages: &'a [SharedMessage],
152    /// Skill bodies loaded into the request outside system/history.
153    pub skill_contents: &'a [&'a str],
154    /// Memory bodies loaded into the request outside system/history.
155    pub memory_contents: &'a [&'a str],
156    /// Reasoning/thinking budget in tokens (0 = adaptive/off, reserved as
157    /// [`ZERO_BUDGET_THINKING_RESERVE_TOKENS`]).
158    pub thinking_budget_tokens: u64,
159    /// Byte cap of the next likely tool result (converted to a token
160    /// reserve at [`TOOL_RESULT_BYTES_PER_TOKEN`]).
161    pub next_tool_result_bytes: u64,
162    /// The request's output ceiling (its `max_tokens`). Bounds the output
163    /// reserve; it is not itself reserved ([`EXPECTED_RESPONSE_TOKENS`]).
164    pub max_output_tokens: u64,
165}
166
167/// Typed per-segment usage accounting.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub struct ContextBreakdown {
170    pub system_tokens: u64,
171    pub tool_schema_tokens: u64,
172    pub history_tokens: u64,
173    pub framing_tokens: u64,
174    pub skill_tokens: u64,
175    pub memory_tokens: u64,
176}
177
178impl ContextBreakdown {
179    pub fn total(&self) -> u64 {
180        self.system_tokens
181            + self.tool_schema_tokens
182            + self.history_tokens
183            + self.framing_tokens
184            + self.skill_tokens
185            + self.memory_tokens
186    }
187}
188
189/// How a provider's wire accounts reasoning/thinking tokens against the
190/// request's output budget (I1, CP-12 review). The reserve model must count
191/// thinking exactly once, and provider differences are represented
192/// explicitly instead of being summed blindly.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum ThinkingAccounting {
195    /// Thinking spends from the request's output cap: Anthropic messages
196    /// (`thinking.budget_tokens < max_tokens`), OpenAI Responses/Chat and
197    /// Codex wires (reasoning tokens count toward `max_output_tokens` /
198    /// `max_completion_tokens`). Reserve ONE envelope:
199    /// `min(max_tokens, thinking + EXPECTED_RESPONSE_TOKENS)`.
200    InsideOutputBudget,
201    /// Thinking is budgeted separately from response output (Gemini
202    /// `thinkingBudget`), or the wire is unknown — reserve both (thinking,
203    /// and `min(max_tokens, EXPECTED_RESPONSE_TOKENS)`), which errs toward
204    /// earlier compaction, never toward exhaustion.
205    SeparateFromOutput,
206}
207
208/// Resolve the thinking-accounting semantics for a model from its wire
209/// protocol. Unroutable models use the conservative separate-reserve model.
210pub fn thinking_accounting_for_model(model: &str) -> ThinkingAccounting {
211    use crate::runtime::openai::WireProtocol;
212    match crate::runtime::openai::resolve_route(model).map(|route| route.wire) {
213        Some(
214            WireProtocol::AnthropicMessages
215            | WireProtocol::OpenAiChatCompletions
216            | WireProtocol::OpenAiResponses
217            | WireProtocol::CodexResponses,
218        ) => ThinkingAccounting::InsideOutputBudget,
219        Some(WireProtocol::GoogleGeminiCodeAssist) | None => ThinkingAccounting::SeparateFromOutput,
220    }
221}
222
223/// Typed reserves carved out of the provider window before any context is
224/// admitted.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub struct ContextReserves {
227    /// Thinking tokens reserved IN ADDITION to the output envelope. Zero on
228    /// wires where thinking already spends from the output cap.
229    pub thinking_tokens: u64,
230    pub tool_result_tokens: u64,
231    /// The output the next round is expected to need, never more than the
232    /// request's `max_tokens`. On [`ThinkingAccounting::InsideOutputBudget`]
233    /// wires thinking spends from the output cap, so this is
234    /// `min(max_tokens, thinking + EXPECTED_RESPONSE_TOKENS)`; on separate
235    /// wires `min(max_tokens, EXPECTED_RESPONSE_TOKENS)` beside
236    /// `thinking_tokens`.
237    pub output_tokens: u64,
238    pub safety_margin_tokens: u64,
239    /// The wire semantics the thinking/output split was computed under.
240    pub thinking_accounting: ThinkingAccounting,
241}
242
243impl ContextReserves {
244    pub fn total(&self) -> u64 {
245        self.thinking_tokens
246            + self.tool_result_tokens
247            + self.output_tokens
248            + self.safety_margin_tokens
249    }
250}
251
252/// One request-aware budget assessment. Pure data — frontends read the
253/// decision, they never recompute it.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub struct ContextAssessment {
256    pub provider_window: u64,
257    pub breakdown: ContextBreakdown,
258    pub reserves: ContextReserves,
259    pub history_messages: usize,
260    /// Footprint measured from the provider's count of an earlier request
261    /// this one extends ([`UsageAnchor`]); `None` = estimate only.
262    pub anchored_tokens: Option<u64>,
263}
264
265impl ContextAssessment {
266    /// The request's footprint: anchored to the provider's last reported
267    /// input when this request extends that one, otherwise the estimate.
268    pub fn used_tokens(&self) -> u64 {
269        self.anchored_tokens
270            .unwrap_or_else(|| self.estimated_tokens())
271    }
272
273    /// Conservative estimate across every request segment, whether or not
274    /// an anchor applies. Calibration samples and the fixed-part overhead
275    /// of successor sizing are in these units.
276    pub fn estimated_tokens(&self) -> u64 {
277        self.breakdown.total()
278    }
279
280    /// The estimate of the request's fixed part: system, tool schemas,
281    /// skills and memory (everything but the messages and their framing).
282    pub fn fixed_tokens(&self) -> u64 {
283        let b = &self.breakdown;
284        b.total() - b.history_tokens - b.framing_tokens
285    }
286
287    /// Anchor this assessment to `anchor` when `messages` (the messages it
288    /// assessed) extend the anchored request's verbatim.
289    pub fn anchored(mut self, anchor: Option<&UsageAnchor>, messages: &[SharedMessage]) -> Self {
290        self.anchored_tokens =
291            anchor.and_then(|a| a.footprint(self.fixed_tokens(), messages));
292        self
293    }
294
295    /// Tokens the context may occupy after all reserves: window minus
296    /// reserves, floored at zero (a window smaller than the reserves has no
297    /// admissible context at all).
298    pub fn budget_tokens(&self) -> u64 {
299        self.provider_window.saturating_sub(self.reserves.total())
300    }
301
302    /// Remaining admissible capacity before the trigger fires.
303    pub fn remaining_tokens(&self) -> u64 {
304        self.budget_tokens().saturating_sub(self.used_tokens())
305    }
306
307    /// The single cross-frontend compaction trigger: usage reached the
308    /// budget AND there is enough history for a summary to fold.
309    pub fn should_compact(&self) -> bool {
310        self.history_messages >= MIN_COMPACTION_MESSAGES
311            && self.used_tokens() >= self.budget_tokens()
312    }
313}
314
315/// Compute the request-aware context assessment. This is THE budget
316/// calculation — `Runtime::assess_context` and every frontend trigger path
317/// route through here.
318pub fn assess(inputs: &ContextBudgetInputs<'_>) -> ContextAssessment {
319    let estimator = estimator_for_model(inputs.model);
320
321    let system_tokens = inputs
322        .system_prompt
323        .map(|s| estimator.estimate(s))
324        .unwrap_or(0);
325
326    // Schemas and history are charged on their SERIALIZED form: JSON keys,
327    // quoting, and escaping are bytes the provider tokenizes too.
328    let tool_schema_tokens: u64 = inputs
329        .tools_schema
330        .iter()
331        .map(|schema| estimate_serialized(&estimator, schema))
332        .sum();
333
334    let history_tokens: u64 = inputs
335        .messages
336        .iter()
337        .map(|msg| estimate_message(&estimator, msg))
338        .sum();
339    let framing_tokens = PER_MESSAGE_FRAMING_TOKENS * inputs.messages.len() as u64;
340
341    let skill_tokens: u64 = inputs
342        .skill_contents
343        .iter()
344        .map(|s| estimator.estimate(s))
345        .sum();
346    let memory_tokens: u64 = inputs
347        .memory_contents
348        .iter()
349        .map(|s| estimator.estimate(s))
350        .sum();
351
352    ContextAssessment {
353        provider_window: inputs.provider_window,
354        breakdown: ContextBreakdown {
355            system_tokens,
356            tool_schema_tokens,
357            history_tokens,
358            framing_tokens,
359            skill_tokens,
360            memory_tokens,
361        },
362        reserves: {
363            let accounting = thinking_accounting_for_model(inputs.model);
364            let thinking = match inputs.thinking_budget_tokens {
365                0 => ZERO_BUDGET_THINKING_RESERVE_TOKENS,
366                budget => budget,
367            };
368            let cap = inputs.max_output_tokens;
369            let (thinking_tokens, output_tokens) = match accounting {
370                ThinkingAccounting::InsideOutputBudget => (
371                    0,
372                    cap.min(thinking.saturating_add(EXPECTED_RESPONSE_TOKENS)),
373                ),
374                ThinkingAccounting::SeparateFromOutput => {
375                    (thinking, cap.min(EXPECTED_RESPONSE_TOKENS))
376                }
377            };
378            ContextReserves {
379                thinking_tokens,
380                tool_result_tokens: inputs
381                    .next_tool_result_bytes
382                    .div_ceil(TOOL_RESULT_BYTES_PER_TOKEN),
383                output_tokens,
384                safety_margin_tokens: inputs.provider_window * SAFETY_MARGIN_PERCENT / 100,
385                thinking_accounting: accounting,
386            }
387        },
388        history_messages: inputs.messages.len(),
389        anchored_tokens: None,
390    }
391}
392
393/// The provider-reported input of one request (`input + cache_read +
394/// cache_write`), kept to measure the requests that follow it.
395///
396/// The estimator cannot know what the provider bills: a thinking block's
397/// signature carries reasoning the visible (summarized) text does not show,
398/// and how much of earlier turns' reasoning bills varies by request. The
399/// provider's count is exact, so the next request is measured as that count
400/// plus an estimate of only what was appended since. Requests that rewrite
401/// the history (rollover, compaction, a changed image view) do not extend
402/// the anchored one and fall back to the estimate.
403#[derive(Clone, Debug)]
404pub struct UsageAnchor {
405    pub provider_tokens: u64,
406    /// Estimate of the anchored request's fixed part
407    /// ([`ContextAssessment::fixed_tokens`]).
408    pub fixed_tokens: u64,
409    /// The anchored request's messages as they were assessed.
410    pub messages: Vec<SharedMessage>,
411}
412
413impl UsageAnchor {
414    /// Footprint of a request with fixed part `fixed_tokens` (estimated) and
415    /// `messages`: the provider's count plus the estimate of every message
416    /// past the ones the two requests share, or `None` when they diverge
417    /// before the anchored request's last message.
418    ///
419    /// The last anchored message may differ (a per-turn injection rides the
420    /// request tail and is not kept in history): what it billed stays
421    /// counted beside the estimate of its replacement. Growth of the fixed
422    /// part is added; neither it nor a shrink is ever subtracted (the
423    /// estimate of what left may exceed what it billed). Every deviation
424    /// therefore errs high, never below the provider's count.
425    pub fn footprint(&self, fixed_tokens: u64, messages: &[SharedMessage]) -> Option<u64> {
426        let shared = self
427            .messages
428            .iter()
429            .zip(messages)
430            .take_while(|(a, b)| super::continuation::same_on_wire(a, b))
431            .count();
432        (shared + 1 >= self.messages.len()).then(|| {
433            self.provider_tokens
434                .saturating_add(estimate_history(&messages[shared..]))
435                .saturating_add(fixed_tokens.saturating_sub(self.fixed_tokens))
436        })
437    }
438}
439
440fn estimate_serialized<T: serde::Serialize + ?Sized>(estimator: &TokenEstimator, value: &T) -> u64 {
441    match serde_json::to_string(value) {
442        Ok(s) => estimator.estimate(&s),
443        // Serialization of an in-memory Value cannot realistically fail;
444        // if it ever does, fail toward compaction rather than exhaustion.
445        Err(_) => u64::MAX / 1024,
446    }
447}
448
449/// Attachment-aware history estimate shared with rollover's reduction check.
450/// Does not charge megabytes of image base64 as ordinary prompt tokens.
451pub(crate) fn estimate_history(messages: &[SharedMessage]) -> u64 {
452    messages.iter().fold(0u64, |sum, message| {
453        sum.saturating_add(estimate_message(&TokenEstimator::Conservative, message))
454            .saturating_add(PER_MESSAGE_FRAMING_TOKENS)
455    })
456}
457
458/// One message as it goes on the wire: without its host-only `_synaps_*`
459/// keys (stripped before every provider request). Messages without base64
460/// images or reasoning blocks are charged on their serialized form; the
461/// others block by block, images at [`IMAGE_TOKEN_ESTIMATE`] and reasoning
462/// by [`estimate_reasoning_block`]. Nested `tool_result.content[]` images
463/// are charged flat too.
464fn estimate_message(estimator: &TokenEstimator, msg: &Value) -> u64 {
465    let wire = super::continuation::WireMessage(msg);
466    let Some(blocks) = msg["content"].as_array() else {
467        return estimate_serialized(estimator, &wire);
468    };
469    // Zero drift for messages without images or reasoning: charge exactly
470    // as before.
471    if !blocks.iter().any(|b| {
472        is_base64_image(b)
473            || is_reasoning_block(b)
474            || b["content"]
475                .as_array()
476                .is_some_and(|a| a.iter().any(is_base64_image))
477    }) {
478        return estimate_serialized(estimator, &wire);
479    }
480    let mut total = 0u64;
481    for block in blocks {
482        if is_base64_image(block) {
483            total += IMAGE_TOKEN_ESTIMATE;
484            continue;
485        }
486        if is_reasoning_block(block) {
487            total += estimate_reasoning_block(estimator, block);
488            continue;
489        }
490        if block["type"] == "tool_result" {
491            if let Some(inner) = block["content"].as_array() {
492                total += 8; // wrapper keys: type, tool_use_id
493                for b in inner {
494                    total += if is_base64_image(b) {
495                        IMAGE_TOKEN_ESTIMATE
496                    } else {
497                        estimate_serialized(estimator, b)
498                    };
499                }
500                continue;
501            }
502        }
503        total += estimate_serialized(estimator, block);
504    }
505    total
506}
507
508/// Base64 signature (or `redacted_thinking` data) characters per estimated
509/// token. See [`estimate_reasoning_block`].
510const REASONING_SIGNATURE_CHARS_PER_TOKEN: u64 = 4;
511
512/// Block keys and delimiters of a reasoning block.
513const REASONING_BLOCK_FRAMING_TOKENS: u64 = 8;
514
515fn is_reasoning_block(b: &Value) -> bool {
516    b["type"] == "thinking" || b["type"] == "redacted_thinking"
517}
518
519/// A `thinking` / `redacted_thinking` block, charged at the larger of its
520/// visible text (conservative estimate) and one token per
521/// [`REASONING_SIGNATURE_CHARS_PER_TOKEN`] signature characters, plus
522/// framing.
523///
524/// The signature is opaque base64 that carries the full reasoning. The
525/// visible text is a summary (`display: "summarized"`) or empty. Neither is
526/// billed as text. Charging the serialized block at 2.5 characters per
527/// token billed the signature as text on top of its summary, about 2.4×
528/// what the provider bills for the block, which read xhigh-reasoning
529/// histories at 1.3–2× their billed input and rolled workers over at about
530/// half the configured size. Measured against Anthropic's billed input
531/// (Opus, 2026-10): appended reasoning blocks bill a median of ~0.17 tokens
532/// per serialized byte of the block (10th–90th percentile 0.11–0.22). This
533/// charge (0.25 per signature character, the signature being most of the
534/// block) sits near the top of that range. Some requests bill earlier
535/// reasoning lower still, which the estimator cannot see; the
536/// provider-anchored footprint ([`UsageAnchor`]) measures those.
537fn estimate_reasoning_block(estimator: &TokenEstimator, block: &Value) -> u64 {
538    let text = block["thinking"].as_str().map_or(0, |t| estimator.estimate(t));
539    let opaque = block["signature"]
540        .as_str()
541        .or_else(|| block["data"].as_str())
542        .map_or(0, |s| s.len() as u64 / REASONING_SIGNATURE_CHARS_PER_TOKEN);
543    text.max(opaque) + REASONING_BLOCK_FRAMING_TOKENS
544}
545
546fn is_base64_image(b: &Value) -> bool {
547    b["type"] == "image" && b["source"]["type"] == "base64"
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use serde_json::json;
554    use std::sync::Arc;
555
556    fn history_tokens_of(messages: &[SharedMessage]) -> u64 {
557        let inputs = ContextBudgetInputs {
558            model: "claude-sonnet-4-6",
559            provider_window: 200_000,
560            system_prompt: None,
561            tools_schema: &[],
562            messages,
563            skill_contents: &[],
564            memory_contents: &[],
565            thinking_budget_tokens: 0,
566            next_tool_result_bytes: 0,
567            max_output_tokens: 0,
568        };
569        assess(&inputs).breakdown.history_tokens
570    }
571
572    fn image_block(len: usize) -> Value {
573        json!({
574            "type": "image",
575            "source": {"type": "base64", "media_type": "image/png", "data": "A".repeat(len)}
576        })
577    }
578
579    /// A history rebuilt from a recorded block-shape fixture: block types
580    /// and lengths only (the estimator charges length, not content), no
581    /// content. Returns (fixed-part estimate, messages, billed input).
582    fn shape_fixture(json: &str) -> (u64, Vec<SharedMessage>, u64) {
583        let fixture: Value = serde_json::from_str(json).unwrap();
584        let n = |v: &Value| v.as_u64().unwrap() as usize;
585        let filler = |len: usize| "x".repeat(len);
586        let base64 = |len: usize| {
587            const B64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
588            (0..len).map(|i| B64[(i * 7 + i / 64) % 64] as char).collect::<String>()
589        };
590        let messages = fixture["messages"]
591            .as_array()
592            .unwrap()
593            .iter()
594            .map(|m| {
595                let role = m[0].as_str().unwrap();
596                let blocks = m[1].as_array().unwrap();
597                let content = match blocks[0][0].as_str().unwrap() {
598                    "s" => json!(filler(n(&blocks[0][1]))),
599                    _ => Value::Array(
600                        blocks
601                            .iter()
602                            .map(|b| match b[0].as_str().unwrap() {
603                                // Serialized length: `{"type":"text","text":""}` is 25.
604                                "b" => json!({"type": "text", "text": filler(n(&b[1]).saturating_sub(25))}),
605                                "k" => json!({
606                                    "type": "thinking",
607                                    "thinking": filler(n(&b[1])),
608                                    "signature": base64(n(&b[2])),
609                                }),
610                                "i" => image_block(1_000_000),
611                                other => panic!("block kind {other}"),
612                            })
613                            .collect(),
614                    ),
615                };
616                Arc::new(json!({"role": role, "content": content})) as SharedMessage
617            })
618            .collect();
619        let fixed = conservative_token_estimate(&filler(
620            n(&fixture["system_bytes"]) + n(&fixture["tool_schema_bytes"]),
621        ));
622        (fixed, messages, fixture["billed"].as_u64().unwrap())
623    }
624
625    /// Regression (worker rollover at about half the configured size): a
626    /// real xhigh-reasoning worker history (Opus, 279 messages, 84 signed
627    /// thinking blocks) the provider billed at 197,253 input tokens. The
628    /// estimate used to charge each thinking block's base64 signature as
629    /// text on top of its summary: 1.29× the billed count here, 1.3–2× on
630    /// other xhigh workers, so the 200k rollover fired near 150k real.
631    #[test]
632    fn xhigh_reasoning_history_estimate_tracks_its_billed_input() {
633        let (fixed, messages, billed) =
634            shape_fixture(include_str!("../../tests/fixtures/context_estimate/xhigh_worker_197k.json"));
635        assert_eq!(billed, 197_253);
636        assert!(messages.iter().any(|m| m["content"]
637            .as_array()
638            .is_some_and(|b| b.iter().any(|b| b["signature"].as_str().is_some_and(|s| s.len() > 1_000)))));
639        let estimate = fixed + estimate_history(&messages);
640        let ratio = estimate as f64 / billed as f64;
641        assert!((0.9..=1.1).contains(&ratio), "estimate {estimate} vs billed {billed}: {ratio:.3}");
642
643        // What the serialized charge (signature as text) read for it.
644        let serialized: u64 = messages
645            .iter()
646            .map(|m| {
647                estimate_serialized(&TokenEstimator::Conservative, &**m) + PER_MESSAGE_FRAMING_TOKENS
648            })
649            .sum();
650        let old = (fixed + serialized) as f64 / billed as f64;
651        assert!(old > 1.25, "the old charge read {old:.3}×");
652    }
653
654    #[test]
655    fn reasoning_blocks_charge_the_larger_of_text_and_signature() {
656        let block = |text: usize, sig: usize| {
657            json!({"type": "thinking", "thinking": "t".repeat(text), "signature": "S".repeat(sig)})
658        };
659        let one = |b: Value| {
660            estimate_history(&[Arc::new(json!({"role": "assistant", "content": [b]}))])
661                - PER_MESSAGE_FRAMING_TOKENS
662                - REASONING_BLOCK_FRAMING_TOKENS
663        };
664        // Summary longer than the signature: the text (2.5 chars/token).
665        assert_eq!(one(block(10_000, 4_000)), 4_000);
666        // Signature-dominated (omitted or short summary): 4 chars/token,
667        // never the serialized sum of both.
668        assert_eq!(one(block(0, 40_000)), 10_000);
669        assert_eq!(one(block(2_500, 40_000)), 10_000);
670        assert_eq!(
671            one(json!({"type": "redacted_thinking", "data": "D".repeat(8_000)})),
672            2_000
673        );
674        // Other blocks beside reasoning are still charged serialized.
675        let mixed = estimate_history(&[Arc::new(json!({"role": "assistant", "content": [
676            block(0, 400),
677            {"type": "text", "text": "a".repeat(2_500)},
678        ]}))]);
679        assert!((1_100..1_130).contains(&mixed), "{mixed}");
680    }
681
682    /// Host-only `_synaps_*` keys never reach the wire, so they are not
683    /// charged.
684    #[test]
685    fn host_only_keys_are_not_charged() {
686        let plain = json!({"role": "user", "content": "hello there"});
687        let mut stamped = plain.clone();
688        stamped["_synaps_at"] = json!("2026-10-30T11:00:00.000Z");
689        stamped["_synaps_turn"] = json!({"id": "x".repeat(400)});
690        assert_eq!(
691            estimate_history(&[Arc::new(stamped.clone())]),
692            estimate_history(&[Arc::new(plain)])
693        );
694        let mut reasoning = json!({"role": "assistant", "content": [
695            {"type": "thinking", "thinking": "plan", "signature": "S".repeat(40)},
696        ]});
697        let bare = estimate_history(&[Arc::new(reasoning.clone())]);
698        reasoning["_synaps_turn"] = json!({"id": "x".repeat(400)});
699        assert_eq!(estimate_history(&[Arc::new(reasoning)]), bare);
700    }
701
702    fn msg(role: &str, text: &str) -> SharedMessage {
703        Arc::new(json!({"role": role, "content": text}))
704    }
705
706    fn assessment_of(system: &str, messages: &[SharedMessage]) -> ContextAssessment {
707        assess(&ContextBudgetInputs {
708            model: "claude-opus-5-5",
709            provider_window: 1_000_000,
710            system_prompt: Some(system),
711            tools_schema: &[],
712            messages,
713            skill_contents: &[],
714            memory_contents: &[],
715            thinking_budget_tokens: 0,
716            next_tool_result_bytes: 0,
717            max_output_tokens: 128_000,
718        })
719    }
720
721    /// The footprint of a request that extends an earlier one is the
722    /// provider's count of that one plus the estimate of only what was
723    /// appended since; the estimate of the shared part plays no role.
724    #[test]
725    fn footprint_is_anchored_to_the_last_reported_usage() {
726        let system = "s".repeat(2_500);
727        let earlier = vec![msg("user", "task"), msg("assistant", &"a".repeat(250_000))];
728        let anchor = UsageAnchor {
729            provider_tokens: 60_000,
730            fixed_tokens: assessment_of(&system, &earlier).fixed_tokens(),
731            messages: earlier.clone(),
732        };
733        let appended = [msg("user", &"r".repeat(25_000))];
734        let mut next = earlier.clone();
735        next.extend(appended.iter().cloned());
736        let assessment = assessment_of(&system, &next).anchored(Some(&anchor), &next);
737        assert_eq!(
738            assessment.used_tokens(),
739            60_000 + estimate_history(&appended),
740            "provider count + appended estimate"
741        );
742        // The estimate is still there for calibration and overhead sizing.
743        assert!(assessment.estimated_tokens() > 100_000);
744        assert_eq!(assessment.anchored_tokens, Some(assessment.used_tokens()));
745
746        // A host stamp on a shared message does not change the wire.
747        let mut stamped = next.clone();
748        let mut first = (*stamped[0]).clone();
749        first["_synaps_at"] = json!("2026-10-30T11:00:00.000Z");
750        stamped[0] = Arc::new(first);
751        assert_eq!(
752            assessment_of(&system, &stamped).anchored(Some(&anchor), &stamped).used_tokens(),
753            assessment.used_tokens()
754        );
755
756        // A larger system prompt adds its growth; a smaller one subtracts
757        // nothing.
758        let grown = "s".repeat(5_000);
759        assert_eq!(
760            assessment_of(&grown, &next).anchored(Some(&anchor), &next).used_tokens(),
761            assessment.used_tokens() + 1_000
762        );
763        assert_eq!(
764            assessment_of("", &next).anchored(Some(&anchor), &next).used_tokens(),
765            assessment.used_tokens()
766        );
767    }
768
769    /// The anchored request's last message may differ (per-turn injection
770    /// rides the request tail): its billed size stays counted, the
771    /// replacement is estimated on top, so the result never drops below the
772    /// provider's count.
773    #[test]
774    fn a_changed_request_tail_keeps_the_anchor_and_errs_high() {
775        let base = vec![msg("user", "task"), msg("assistant", &"a".repeat(25_000))];
776        let mut injected = base.clone();
777        injected.push(msg("user", &format!("next{}", "i".repeat(5_000))));
778        let anchor = UsageAnchor {
779            provider_tokens: 30_000,
780            fixed_tokens: 0,
781            messages: injected,
782        };
783        let mut next = base.clone();
784        next.push(msg("user", "next"));
785        next.push(msg("assistant", &"b".repeat(2_500)));
786        assert_eq!(
787            anchor.footprint(0, &next),
788            Some(30_000 + estimate_history(&next[2..]))
789        );
790        // A shorter request that keeps all but the last: the count stands.
791        assert_eq!(anchor.footprint(0, &base), Some(30_000));
792    }
793
794    /// Without a usable anchor (first request, after resume or rollover, a
795    /// provider without usage, or a rewritten history) the footprint is the
796    /// estimate.
797    #[test]
798    fn footprint_falls_back_to_the_estimate_without_an_anchor() {
799        let system = "s".repeat(2_500);
800        let history = vec![
801            msg("user", "task"),
802            msg("assistant", &"a".repeat(25_000)),
803            msg("user", "more"),
804            msg("assistant", &"b".repeat(25_000)),
805        ];
806        let plain = assessment_of(&system, &history);
807        assert_eq!(plain.anchored_tokens, None);
808        assert_eq!(plain.used_tokens(), plain.estimated_tokens());
809        assert_eq!(plain.anchored(None, &history).used_tokens(), plain.estimated_tokens());
810
811        // A rollover successor: the anchored history was rewritten before
812        // its last message.
813        let anchor = UsageAnchor {
814            provider_tokens: 5_000,
815            fixed_tokens: plain.fixed_tokens(),
816            messages: history.clone(),
817        };
818        let successor = vec![history[0].clone(), msg("user", "[host note]"), history[3].clone()];
819        let rolled = assessment_of(&system, &successor);
820        assert_eq!(
821            rolled.anchored(Some(&anchor), &successor).used_tokens(),
822            rolled.estimated_tokens()
823        );
824    }
825
826    #[test]
827    fn image_block_in_tool_result_charged_flat() {
828        let messages: Vec<SharedMessage> = vec![Arc::new(json!({
829            "role": "user",
830            "content": [{
831                "type": "tool_result",
832                "tool_use_id": "toolu_1",
833                "content": [
834                    {"type": "text", "text": "Image: /tmp/x.png (1024x1536, image/png, 2292 KB)"},
835                    image_block(3_000_000),
836                ]
837            }]
838        }))];
839        let tokens = history_tokens_of(&messages);
840        assert!(tokens >= IMAGE_TOKEN_ESTIMATE, "{tokens}");
841        assert!(tokens <= IMAGE_TOKEN_ESTIMATE + 200, "{tokens}");
842        assert!(tokens < 10_000, "{tokens}");
843    }
844
845    #[test]
846    fn user_message_image_charged_flat() {
847        let messages: Vec<SharedMessage> = vec![Arc::new(json!({
848            "role": "user",
849            "content": [
850                {"type": "text", "text": "what is this?"},
851                image_block(3_000_000),
852            ]
853        }))];
854        let tokens = history_tokens_of(&messages);
855        assert!(tokens >= IMAGE_TOKEN_ESTIMATE, "{tokens}");
856        assert!(tokens <= IMAGE_TOKEN_ESTIMATE + 200, "{tokens}");
857    }
858
859    #[test]
860    fn string_content_unchanged() {
861        let msg = json!({"role": "user", "content": "plain string content, no blocks here"});
862        let estimator = estimator_for_model("claude-sonnet-4-6");
863        assert_eq!(
864            estimate_message(&estimator, &msg),
865            estimate_serialized(&estimator, &msg)
866        );
867    }
868
869    #[test]
870    fn array_content_without_images_unchanged() {
871        let estimator = estimator_for_model("claude-sonnet-4-6");
872        for msg in [
873            json!({"role": "assistant", "content": [
874                {"type": "text", "text": "let me look"},
875                {"type": "tool_use", "id": "toolu_1", "name": "read", "input": {"path": "x"}}
876            ]}),
877            json!({"role": "user", "content": [
878                {"type": "tool_result", "tool_use_id": "toolu_1", "content": "1\tfoo\n2\tbar"}
879            ]}),
880            json!({"role": "user", "content": [
881                {"type": "tool_result", "tool_use_id": "toolu_1", "content": [
882                    {"type": "text", "text": "nested text only"}
883                ]}
884            ]}),
885        ] {
886            assert_eq!(
887                estimate_message(&estimator, &msg),
888                estimate_serialized(&estimator, &msg),
889                "{msg}"
890            );
891        }
892    }
893
894    #[test]
895    fn non_image_blocks_still_charged_by_payload() {
896        // A big text block must NOT get the flat image discount.
897        let msg = json!({"role": "user", "content": [
898            {"type": "text", "text": "A".repeat(100_000)}
899        ]});
900        let estimator = estimator_for_model("claude-sonnet-4-6");
901        assert!(estimate_message(&estimator, &msg) > 10_000);
902    }
903
904    #[test]
905    fn ascii_is_charged_at_the_dense_bpe_rate() {
906        // 25 ASCII chars → ceil(25 / 2.5) = 10 tokens.
907        assert_eq!(conservative_token_estimate(&"a".repeat(25)), 10);
908        assert_eq!(conservative_token_estimate(""), 0);
909    }
910
911    #[test]
912    fn non_ascii_is_charged_per_utf8_byte() {
913        // CJK: 3 bytes/char → 3 tokens each.
914        assert_eq!(conservative_token_estimate("你好"), 6);
915        // 4-byte emoji scalar → 4 tokens.
916        assert_eq!(conservative_token_estimate("😀"), 4);
917        // 2-byte Cyrillic → 2 tokens each.
918        assert_eq!(conservative_token_estimate("да"), 4);
919    }
920
921    #[test]
922    fn tiny_window_never_underflows_and_fails_toward_compaction() {
923        let messages: Vec<SharedMessage> = (0..MIN_COMPACTION_MESSAGES)
924            .map(|i| Arc::new(json!({"role": "user", "content": format!("m{i}")})) as SharedMessage)
925            .collect();
926        let assessment = assess(&ContextBudgetInputs {
927            model: "claude-sonnet-4-6",
928            provider_window: 10,
929            system_prompt: None,
930            tools_schema: &[],
931            messages: &messages,
932            skill_contents: &[],
933            memory_contents: &[],
934            thinking_budget_tokens: 1_000,
935            next_tool_result_bytes: 0,
936            max_output_tokens: 64_000,
937        });
938        assert_eq!(assessment.budget_tokens(), 0);
939        assert_eq!(assessment.remaining_tokens(), 0);
940        assert!(assessment.should_compact());
941    }
942
943    /// I1 (CP-12 review): Anthropic-wire requests spend thinking from
944    /// `max_tokens` — the reserve model must count thinking exactly once.
945    #[test]
946    fn anthropic_wire_counts_thinking_exactly_once_inside_the_output_reserve() {
947        let messages: Vec<SharedMessage> =
948            vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
949        let window = 200_000;
950        let margin = window * SAFETY_MARGIN_PERCENT / 100;
951        let inputs = ContextBudgetInputs {
952            model: "claude-sonnet-4-6",
953            provider_window: window,
954            system_prompt: None,
955            tools_schema: &[],
956            messages: &messages,
957            skill_contents: &[],
958            memory_contents: &[],
959            thinking_budget_tokens: 8_000,
960            next_tool_result_bytes: 0,
961            max_output_tokens: 64_000,
962        };
963        let assessment = assess(&inputs);
964        assert_eq!(
965            assessment.reserves.thinking_accounting,
966            ThinkingAccounting::InsideOutputBudget
967        );
968        assert_eq!(
969            assessment.reserves.thinking_tokens, 0,
970            "thinking must not be reserved a second time on the Anthropic wire"
971        );
972        let envelope = 8_000 + EXPECTED_RESPONSE_TOKENS;
973        assert_eq!(assessment.reserves.output_tokens, envelope);
974        assert_eq!(
975            assessment.budget_tokens(),
976            window - (envelope + margin),
977            "budget must subtract the output/thinking envelope exactly once"
978        );
979
980        // The output cap bounds the envelope: thinking spends from
981        // `max_tokens`, so no round can produce more than it.
982        let big_thinking = ContextBudgetInputs {
983            thinking_budget_tokens: 100_000,
984            ..inputs
985        };
986        let assessment = assess(&big_thinking);
987        assert_eq!(assessment.reserves.output_tokens, 64_000);
988        assert_eq!(assessment.reserves.thinking_tokens, 0);
989
990        // A zero budget (adaptive: the model decides) reserves the high tier.
991        let adaptive = assess(&ContextBudgetInputs {
992            thinking_budget_tokens: 0,
993            ..inputs
994        });
995        assert_eq!(
996            adaptive.reserves.output_tokens,
997            ZERO_BUDGET_THINKING_RESERVE_TOKENS + EXPECTED_RESPONSE_TOKENS
998        );
999    }
1000
1001    /// The output reserve is the next round's realistic need, not the
1002    /// `max_tokens` ceiling (128k for Opus): the hard-capacity line of a
1003    /// 200k window sits above 100k for Opus and Sonnet alike, and a 1m
1004    /// window keeps the 15% margin as its largest reserve.
1005    #[test]
1006    fn the_output_reserve_is_a_realistic_need_not_the_max_tokens_ceiling() {
1007        let hard_line = |model: &str, window: u64, thinking: u64| {
1008            let (assessment, budget) = crate::runtime::stream::pre_request_context_budget(
1009                model, window, None, &[], &[], thinking, 30_000, None,
1010            );
1011            (assessment.reserves.output_tokens, window - budget.required_next_round_tokens)
1012        };
1013        // Adaptive (0) and "high" (16,384) reserve the same envelope.
1014        for thinking in [0, 16_384] {
1015            for model in ["claude-opus-5-5", "claude-sonnet-4-6"] {
1016                // 32,768 output + 10,000 tool result + 30,000 margin + 512 advisory.
1017                assert_eq!(hard_line(model, 200_000, thinking), (32_768, 126_720), "{model}");
1018                assert_eq!(hard_line(model, 1_000_000, thinking), (32_768, 806_720), "{model}");
1019            }
1020        }
1021        // "medium" (4,096), the runtime default.
1022        assert_eq!(hard_line("claude-opus-5-5", 200_000, 4_096), (20_480, 139_008));
1023        // "xhigh" (32,768) on Opus: still above the 93k successor target.
1024        assert_eq!(hard_line("claude-opus-5-5", 200_000, 32_768), (49_152, 110_336));
1025        // A budget past the model's cap: the cap (64k on Sonnet) governs.
1026        assert_eq!(hard_line("claude-sonnet-4-6", 200_000, 100_000).0, 64_000);
1027    }
1028
1029    /// OpenAI Responses/Chat wires bill reasoning inside the output cap too.
1030    #[test]
1031    fn openai_wires_account_reasoning_inside_the_output_reserve() {
1032        for model in ["openai-codex/gpt-5.2-codex", "xai-auth/grok-4"] {
1033            let accounting = thinking_accounting_for_model(model);
1034            // xai-auth requires a cataloged model; skip honestly if the
1035            // catalog drops it rather than asserting a stale fixture.
1036            if model.starts_with("xai-auth") && accounting == ThinkingAccounting::SeparateFromOutput
1037            {
1038                continue;
1039            }
1040            assert_eq!(
1041                accounting,
1042                ThinkingAccounting::InsideOutputBudget,
1043                "{model}"
1044            );
1045        }
1046    }
1047
1048    /// Gemini budgets thinking separately from response output; unroutable
1049    /// models fall back to the conservative separate-reserve model.
1050    #[test]
1051    fn gemini_and_unknown_models_reserve_thinking_separately() {
1052        let messages: Vec<SharedMessage> =
1053            vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
1054        let window = 200_000;
1055        let margin = window * SAFETY_MARGIN_PERCENT / 100;
1056        for model in ["google-gemini/gemini-2.5-pro", "totally/unroutable-model"] {
1057            let assessment = assess(&ContextBudgetInputs {
1058                model,
1059                provider_window: window,
1060                system_prompt: None,
1061                tools_schema: &[],
1062                messages: &messages,
1063                skill_contents: &[],
1064                memory_contents: &[],
1065                thinking_budget_tokens: 8_000,
1066                next_tool_result_bytes: 0,
1067                max_output_tokens: 64_000,
1068            });
1069            assert_eq!(
1070                assessment.reserves.thinking_accounting,
1071                ThinkingAccounting::SeparateFromOutput,
1072                "{model}"
1073            );
1074            assert_eq!(assessment.reserves.thinking_tokens, 8_000, "{model}");
1075            assert_eq!(
1076                assessment.reserves.output_tokens, EXPECTED_RESPONSE_TOKENS,
1077                "{model}"
1078            );
1079            assert_eq!(
1080                assessment.budget_tokens(),
1081                window - (8_000 + EXPECTED_RESPONSE_TOKENS + margin),
1082                "{model}: separate wires reserve both envelopes"
1083            );
1084        }
1085    }
1086
1087    /// `Runtime::assess_context` is the SAME calculation as `assess` on the
1088    /// runtime's own segments — the Runtime surface must never fork the math.
1089    #[tokio::test]
1090    async fn runtime_assessment_matches_engine_calculation() {
1091        let mut runtime = crate::Runtime::new_headless();
1092        runtime.set_system_prompt("shared system prompt for budget parity".into());
1093        runtime.set_context_window(Some(200_000));
1094
1095        let messages: Vec<SharedMessage> = vec![
1096            Arc::new(json!({"role": "user", "content": "hello 你好 😀"})),
1097            Arc::new(json!({"role": "assistant", "content": [
1098                {"type": "text", "text": "fn main() { println!(\"hi\"); }"}
1099            ]})),
1100        ];
1101
1102        let via_runtime = runtime.assess_context(&messages).await;
1103
1104        let system = runtime.effective_system_prompt().await;
1105        let schema = runtime.tools_shared().read().await.tools_schema();
1106        let direct = assess(&ContextBudgetInputs {
1107            model: runtime.model(),
1108            provider_window: runtime.context_window(),
1109            system_prompt: system.as_deref(),
1110            tools_schema: &schema,
1111            messages: &messages,
1112            skill_contents: &[],
1113            memory_contents: &[],
1114            thinking_budget_tokens: runtime.thinking_budget() as u64,
1115            next_tool_result_bytes: runtime.max_tool_output() as u64,
1116            max_output_tokens: crate::runtime::helpers::HelperMethods::max_tokens_for_model(
1117                runtime.model(),
1118            ),
1119        });
1120
1121        assert_eq!(
1122            via_runtime, direct,
1123            "Runtime surface must not fork the budget math"
1124        );
1125        assert!(
1126            via_runtime.reserves.output_tokens > 0,
1127            "output reserve must be model-derived"
1128        );
1129        assert!(
1130            via_runtime.breakdown.system_tokens > 0,
1131            "actual system segment must be accounted"
1132        );
1133    }
1134
1135    /// Build a synthetic, in-bounds recall contribution for the runtime's
1136    /// own project identity (task A7 budget-wiring tests).
1137    fn synthetic_memory_contribution(
1138        rendered: &str,
1139    ) -> crate::runtime::memory_context::MemoryContextContribution {
1140        use crate::runtime::memory_context as mc;
1141        use agent_core::BoundedText;
1142        mc::MemoryContextContribution {
1143            schema: mc::ContributionSchemaVersion::parse("contribution/1").expect("valid schema"),
1144            provider_id: mc::ContextProviderId::parse("axel-memory").expect("valid provider"),
1145            project_id: crate::runtime::memory_project_id(),
1146            records: vec![mc::MemoryContributionRecord {
1147                memory_id: mc::MemoryId::parse("mem-0001").expect("valid memory id"),
1148                source: mc::MemorySource::ChatHistory,
1149                timestamp: std::time::SystemTime::UNIX_EPOCH,
1150                rank_reason: vec![mc::RankReason::ExactTopic],
1151                sensitivity: agent_core::core::disclosure::DisclosureClass::ModelVisible,
1152                retention: mc::RetentionClass::Standard,
1153                content: BoundedText::new("the project uses session-scoped authorization", 2048),
1154                truncated: false,
1155                supersedes: None,
1156            }],
1157            rendered: BoundedText::new(rendered, 16 * 1024),
1158            accounting: mc::ContributionAccounting::default(),
1159        }
1160    }
1161
1162    /// Task A7 (spec §10.3): a held recall contribution is charged EXACTLY
1163    /// to the `memory_tokens` breakdown lane — `used_tokens`/`remaining_tokens`
1164    /// move by exactly the estimated memory tokens, every other breakdown
1165    /// lane is unchanged, and the typed reserves (thinking, tool-result,
1166    /// output, safety margin) are bit-for-bit identical with and without the
1167    /// contribution: memory competes for budget headroom only, NEVER for
1168    /// reserves. Clearing the contribution restores the assessment
1169    /// bit-for-bit.
1170    #[tokio::test]
1171    async fn memory_contribution_charges_only_the_memory_lane_and_never_reserves() {
1172        let mut runtime = crate::Runtime::new_headless();
1173        runtime.set_system_prompt("system prompt for the memory budget test".into());
1174        runtime.set_context_window(Some(200_000));
1175
1176        let messages: Vec<SharedMessage> = vec![
1177            Arc::new(json!({"role": "user", "content": "what auth model do we use?"})),
1178            Arc::new(json!({"role": "assistant", "content": "let me check"})),
1179        ];
1180
1181        let without = runtime.assess_context(&messages).await;
1182        assert_eq!(without.breakdown.memory_tokens, 0);
1183
1184        let rendered = "[Axel memory — lower-authority project data; verify before relying]\n\
1185                        1. mem-0001 — Decision — the project uses session-scoped \
1186                        authorization rather than persisted grants.";
1187        runtime
1188            .hold_memory_contribution(synthetic_memory_contribution(rendered))
1189            .expect("in-bounds contribution for the runtime's own project is accepted");
1190        let with = runtime.assess_context(&messages).await;
1191
1192        let expected_memory = conservative_token_estimate(rendered);
1193        assert!(expected_memory > 0, "test contribution must be non-empty");
1194        assert_eq!(
1195            with.breakdown.memory_tokens, expected_memory,
1196            "memory lane must carry exactly the estimated rendered tokens"
1197        );
1198
1199        // Reserves: bit-for-bit identical. Memory never touches thinking,
1200        // tool-result, output, or safety-margin reserves.
1201        assert_eq!(
1202            with.reserves, without.reserves,
1203            "reserves must be COMPLETELY unaffected by memory content"
1204        );
1205        assert_eq!(
1206            with.budget_tokens(),
1207            without.budget_tokens(),
1208            "the admissible budget (window minus reserves) must not move"
1209        );
1210
1211        // Every non-memory breakdown lane is unchanged.
1212        assert_eq!(
1213            with.breakdown.system_tokens,
1214            without.breakdown.system_tokens
1215        );
1216        assert_eq!(
1217            with.breakdown.tool_schema_tokens,
1218            without.breakdown.tool_schema_tokens
1219        );
1220        assert_eq!(
1221            with.breakdown.history_tokens,
1222            without.breakdown.history_tokens
1223        );
1224        assert_eq!(
1225            with.breakdown.framing_tokens,
1226            without.breakdown.framing_tokens
1227        );
1228        assert_eq!(with.breakdown.skill_tokens, without.breakdown.skill_tokens);
1229        assert_eq!(with.history_messages, without.history_messages);
1230
1231        // Usage moves by exactly the memory estimate — in both directions.
1232        assert_eq!(with.used_tokens(), without.used_tokens() + expected_memory);
1233        assert_eq!(
1234            with.remaining_tokens(),
1235            without.remaining_tokens() - expected_memory
1236        );
1237
1238        // should_compact still keys off the SAME budget, now with memory
1239        // usage included (not asserted to flip here — the contribution is
1240        // far smaller than the window; the invariant is the inputs).
1241        assert_eq!(
1242            with.should_compact(),
1243            with.history_messages >= MIN_COMPACTION_MESSAGES
1244                && with.used_tokens() >= with.budget_tokens()
1245        );
1246
1247        // Clearing the held segment restores the assessment bit-for-bit —
1248        // the no-contribution path is byte-identical to before task A7.
1249        runtime.clear_memory_contribution();
1250        let cleared = runtime.assess_context(&messages).await;
1251        assert_eq!(
1252            cleared, without,
1253            "no held contribution ⇒ assessment identical to the empty lane"
1254        );
1255    }
1256
1257    /// Task A7 fail-closed gate (spec §5.2): a contribution whose project
1258    /// identity does not match the host's is refused at hold time and never
1259    /// reaches the budget lane.
1260    #[tokio::test]
1261    async fn foreign_project_contribution_is_refused_and_never_budgeted() {
1262        use crate::runtime::memory_context as mc;
1263        let mut runtime = crate::Runtime::new_headless();
1264        runtime.set_context_window(Some(200_000));
1265
1266        let mut contribution = synthetic_memory_contribution("foreign project memory");
1267        contribution.project_id =
1268            mc::ProjectId::parse("project-not-this-host").expect("valid project id");
1269        assert_eq!(
1270            runtime.hold_memory_contribution(contribution),
1271            Err(mc::MemoryContextError::ContributionProjectMismatch)
1272        );
1273
1274        let messages: Vec<SharedMessage> =
1275            vec![Arc::new(json!({"role": "user", "content": "hi"})) as SharedMessage];
1276        let assessment = runtime.assess_context(&messages).await;
1277        assert_eq!(
1278            assessment.breakdown.memory_tokens, 0,
1279            "a refused contribution must never be budgeted"
1280        );
1281    }
1282}