Skip to main content

deepstrike_core/context/
renderer.rs

1#[cfg(test)]
2use super::fault::stable_hash;
3use super::partitions::ContextPartitions;
4use super::task_state::TaskState;
5use super::token_engine::ContextTokenEngine;
6use super::units::{strict_tool_pairing_is_valid, unit_boundaries};
7use crate::mm::handle::{HandleTable, Residency};
8use crate::types::message::{Content, ContentPart, Message, Role};
9use serde::{Deserialize, Serialize};
10
11/// Structured render output aligned with LLM API slots.
12///
13/// Slot 1 — system_stable:    Identity (system partition). Anthropic system[0] cache_control.
14/// Slot 2 — system_knowledge: Knowledge partition. Anthropic system[1] cache_control.
15/// Slot 3 — turns[0..N]:      History turns (stable, cacheable prefix).
16/// Slot 4 — state_turn:       State (task_state + signals), rebuilt every call.
17///
18/// The State turn is kept OUT of `turns` so the history prefix stays byte-stable
19/// across turns and can be prompt-cached. Providers place `state_turn` themselves:
20/// Anthropic appends it AFTER the message-history cache breakpoint (so the volatile
21/// state is the cheap uncached tail); OpenAI-family prepend it (preserving today's
22/// ordering). When this struct is produced by an older binding that has not been
23/// rebuilt, `state_turn` is absent and `turns[0]` still carries the State turn —
24/// providers handle both shapes.
25///
26/// system_text = system_stable + system_knowledge (for OpenAI which has one system slot).
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RenderedContext {
29    /// Identity + Knowledge combined — for providers with a single system slot (OpenAI).
30    pub system_text: String,
31    /// Identity only (system partition). Anthropic system[0] with cache_control.
32    pub system_stable: String,
33    /// Knowledge (memory retrievals, skill definitions, artifacts). Anthropic system[1] with cache_control.
34    pub system_knowledge: String,
35    /// History turns only — the stable, cacheable message prefix.
36    pub turns: Vec<Message>,
37    /// Volatile State turn (task_state + signals), rebuilt every call. Rendered
38    /// after the cacheable history. `None` when there is no task state or signals.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub state_turn: Option<Message>,
41    /// P1-E: number of leading `turns` that form the **frozen prefix** — byte-stable until the
42    /// next compaction. Providers that place explicit cache breakpoints (Anthropic) pin one *deep*
43    /// breakpoint at this boundary (a long-lived cache that survives many turns and is immune to
44    /// the 20-block lookback miss on heavy tool turns) and roll the other at the tail. `None` when
45    /// there is no distinct frozen region yet (pre-first-compaction, or the whole render is hot) —
46    /// providers then fall back to the rolling-pair placement. Providers clamp out-of-range values.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub frozen_prefix_len: Option<usize>,
49    /// Explicit evidence that the fixed context or protected tail exceeded the declared input
50    /// budget. Hosts must not submit this context unchanged; the state machine uses it to trigger
51    /// compaction or terminate with `ContextOverflow`.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub budget_overflow: Option<ContextBudgetOverflow>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum ContextBudgetOverflowKind {
59    FixedContext,
60    ProtectedTail,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ContextBudgetOverflow {
65    pub kind: ContextBudgetOverflowKind,
66    pub required_tokens: u32,
67    pub max_tokens: u32,
68}
69
70/// Per-render fingerprint of the **cacheable prefix** — the segments a provider
71/// caches as a stable prefix (system blocks + history `turns`). Excludes
72/// `state_turn` (the volatile uncached tail) and `token_count` metadata (not on the
73/// wire). This is the metrics-first instrument (P0-A) behind the optimization work:
74/// two renders share a reusable KV / prompt-cache prefix iff their system hashes
75/// match *and* one's `turn_hashes` is a prefix of the other's. Pure and derived —
76/// never stored in snapshots, session logs, or event logs.
77#[cfg(test)]
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub(crate) struct PrefixFingerprint {
80    pub system_stable_hash: u64,
81    pub system_knowledge_hash: u64,
82    /// One stable hash per history turn, in order. The longest common prefix with a
83    /// previous render's vector = how many turns stay cache-reusable across the call.
84    pub turn_hashes: Vec<u64>,
85}
86
87#[cfg(test)]
88impl PrefixFingerprint {
89    /// True when `self`'s cacheable prefix is a byte-stable *extension* of `prev`:
90    /// identical system segments and `prev.turn_hashes` is a prefix of
91    /// `self.turn_hashes`. This is exactly the KV / prompt-cache reuse condition —
92    /// no drift anywhere in the prefix, only growth at the tail.
93    pub(crate) fn extends(&self, prev: &PrefixFingerprint) -> bool {
94        self.system_stable_hash == prev.system_stable_hash
95            && self.system_knowledge_hash == prev.system_knowledge_hash
96            && prev.turn_hashes.len() <= self.turn_hashes.len()
97            && self.turn_hashes[..prev.turn_hashes.len()] == prev.turn_hashes[..]
98    }
99
100    /// Number of leading turns byte-identical to `prev` — the reusable turn-prefix
101    /// length. A drop below `prev.turn_hashes.len()` signals mid-prefix churn (a
102    /// turn rewritten in place, e.g. an in-place collapse) that invalidates cache.
103    pub(crate) fn common_turn_prefix(&self, prev: &PrefixFingerprint) -> usize {
104        self.turn_hashes
105            .iter()
106            .zip(prev.turn_hashes.iter())
107            .take_while(|(a, b)| a == b)
108            .count()
109    }
110}
111
112/// Wire-relevant hash of one turn: role + content + tool_calls, **excluding**
113/// `token_count` (kernel-only metadata that never reaches the provider). Serialised
114/// through serde so every content variant and tool-call argument is covered with a
115/// deterministic field order.
116#[cfg(test)]
117fn hash_turn(msg: &Message) -> u64 {
118    let material =
119        serde_json::to_vec(&(&msg.role, &msg.content, &msg.tool_calls)).unwrap_or_default();
120    stable_hash(&material)
121}
122
123#[cfg(test)]
124impl RenderedContext {
125    /// Compute the [`PrefixFingerprint`] for this render. See its docs for the
126    /// cache-reuse contract it certifies.
127    pub(crate) fn prefix_fingerprint(&self) -> PrefixFingerprint {
128        PrefixFingerprint {
129            system_stable_hash: stable_hash(self.system_stable.as_bytes()),
130            system_knowledge_hash: stable_hash(self.system_knowledge.as_bytes()),
131            turn_hashes: self.turns.iter().map(hash_turn).collect(),
132        }
133    }
134}
135
136fn build_system_stable(partitions: &ContextPartitions) -> String {
137    partitions
138        .system
139        .messages
140        .iter()
141        .filter_map(|m| m.content.as_text())
142        .collect::<Vec<_>>()
143        .join("\n\n")
144}
145
146fn build_system_knowledge(partitions: &ContextPartitions) -> String {
147    partitions
148        .knowledge
149        .messages()
150        .filter_map(|m| m.content.as_text())
151        .collect::<Vec<_>>()
152        .join("\n\n")
153}
154
155/// P1-F (+ 2b/2c): a one-line recency footer at the *last* content before the "Proceed." anchor —
156/// the highest-attention position in the prompt (the model attends most to the final tokens).
157///
158/// It LEADS WITH FORWARD MOTION (what just happened · what to do next · the standing directive), not
159/// a verbatim restatement of the goal. Re-injecting the bare goal at this peak-attention slot every
160/// turn primes the model to *re-narrate intent* ("好的,我来将<goal>…") instead of acting — an
161/// undamped repetition trap when there is no plan/progress to advance. The full goal still LEADS the
162/// TASK STATE block above (primacy + reference), so goal-adherence is preserved; the footer restates
163/// the goal only when nothing has happened yet (e.g. turn 1, no actions). `None` when there is no goal.
164///
165/// The "just did" clause is kernel-derived from `recent_actions` (real tool activity), and a trailing
166/// run of an identical action raises an explicit STOP — a cheap no-progress backstop that breaks the
167/// read→re-read→re-narrate loop in-band, at the position the model weights most.
168fn salience_footer(ts: &TaskState) -> Option<String> {
169    if ts.goal.is_empty() {
170        return None;
171    }
172    let mut clauses: Vec<String> = Vec::new();
173
174    // What just happened — display tool NAMES only. The full `name(args)` signatures are kept in
175    // `recent_actions` for the repeat check below, but rendering them every turn bloats the volatile
176    // footer; the names alone show motion at the peak-attention slot.
177    let recent = ts.recent_actions.as_slice();
178    let action_name = |entry: &str| entry.split('(').next().unwrap_or(entry).to_string();
179    if let Some(last) = recent.last() {
180        let start = recent.len().saturating_sub(3);
181        let names = recent[start..]
182            .iter()
183            .map(|e| action_name(e))
184            .collect::<Vec<_>>()
185            .join(" → ");
186        clauses.push(format!("did: {names}"));
187
188        // No-progress backstop: the SAME call — name AND args — repeated on the last ≥2 turns is a
189        // stall (a legit loop varies its args, so it reads as distinct progress, not a repeat).
190        let trailing_repeat = recent.iter().rev().take_while(|a| *a == last).count();
191        if trailing_repeat >= 2 {
192            clauses.push(format!(
193                "STOP: `{}` repeated {trailing_repeat}× unchanged — do something different or report",
194                action_name(last)
195            ));
196        }
197    }
198
199    // What to do next — the active plan step if the model maintains one, else a short forward nudge.
200    let active_step = ts
201        .current_step
202        .and_then(|i| ts.plan.get(i).map(|s| (i, s)))
203        .filter(|(_, s)| !s.done);
204    if let Some((i, step)) = active_step {
205        clauses.push(format!("next: step {} — {}", i + 1, step.label));
206    } else if !recent.is_empty() {
207        clauses.push("next: advance the goal".to_string());
208    }
209
210    if let Some(d) = ts.directives.last() {
211        clauses.push(format!("must: {d}"));
212    }
213
214    // Lead with the goal only when no forward clause fills the footer (turn 1, nothing done yet);
215    // otherwise the forward clauses carry the salience and the goal stays in the block above.
216    let body = if clauses.is_empty() {
217        format!("→ focus: {}", ts.goal)
218    } else {
219        format!("→ {}", clauses.join(" · "))
220    };
221    Some(body)
222}
223
224/// Build the State turn (the volatile tail): task_state + signals + a recency focus footer +
225/// "Proceed." anchor. The footer sits last (just before "Proceed.") so the current goal/step/
226/// directive land in the prompt's highest-attention position (P1-F).
227fn build_state_turn(partitions: &ContextPartitions) -> Option<Message> {
228    let task = partitions.task_state.format_compact();
229    if task.is_empty() && partitions.signals.is_empty() {
230        return None;
231    }
232    let mut parts: Vec<String> = Vec::new();
233    if !task.is_empty() {
234        parts.push(task);
235    }
236    let signals_text = partitions.signals.join("\n");
237    if !signals_text.is_empty() {
238        parts.push(signals_text);
239    }
240    if let Some(footer) = salience_footer(&partitions.task_state) {
241        parts.push(footer);
242    }
243    let body = parts.join("\n\n");
244    Some(Message::user(format!("{body}\n\nProceed.")))
245}
246
247/// Ensure turns start with a user message.
248/// After AutoCompact the preserved tail may be all assistant/tool — insert an anchor.
249fn normalize_turn_prefix(turns: &mut Vec<Message>) {
250    if !turns.is_empty() && matches!(turns[0].role, Role::Assistant | Role::Tool) {
251        turns.insert(0, Message::user("[context resumed]"));
252    }
253}
254
255/// Layer-4 read-time projection: replace the body of a `Collapsed` tool result with a short
256/// preview, leaving a marker. Non-destructive — the full output stays in `partitions.history`;
257/// only the rendered copy shrinks. Un-collapse is boundary-only (P0-C): handles re-evaluate
258/// from Resident at the next compaction/renewal, never mid-generation (cache-safe monotonic).
259/// Trained-convention truncation marker: says what was cut and how to get it back (the
260/// `read_result` tool + this result's `call_id`), instead of kernel-internal vocabulary
261/// ("projected out of view") the model has never seen in training.
262pub(crate) fn collapse_preview(output: &str, call_id: &str) -> String {
263    const PREVIEW_BYTES: usize = 160;
264    let retrieval = format!(
265        "Call the read_result tool with call_id \"{call_id}\" to re-read the full result.]"
266    );
267    if output
268        .lines()
269        .last()
270        .is_some_and(|line| line.starts_with("[Output truncated:") && line.ends_with(&retrieval))
271    {
272        return output.to_string();
273    }
274    let mut end = PREVIEW_BYTES.min(output.len());
275    while end > 0 && !output.is_char_boundary(end) {
276        end -= 1;
277    }
278    let dropped = output.len().saturating_sub(end);
279    format!(
280        "{}…\n[Output truncated: {dropped} bytes omitted. Call the read_result tool with call_id \"{call_id}\" to re-read the full result.]",
281        &output[..end]
282    )
283}
284
285/// Stub substituted for a collapsed assistant preamble. Carries no goal text (that would re-seed the
286/// very repetition this removes) and points the model at the authoritative State turn instead.
287const NARRATION_STUB: &str = "[earlier narration collapsed; tool call(s) preserved below — current progress is in the TASK STATE block]";
288
289/// Minimum narration length (chars, CJK-aware) worth collapsing. Short preambles aren't worth a
290/// stub substitution (and the one-time cache churn it costs as the turn ages out of the window).
291const NARRATION_COLLAPSE_MIN_CHARS: usize = 40;
292
293/// Method 1: read-time collapse of an OLD assistant turn's narration. Targets exactly the
294/// "preamble before action" turns — `Role::Assistant`, a `Content::Text` body, AND a non-empty
295/// `tool_calls` (the model narrated intent, then acted). Returns a projected copy whose text is
296/// replaced by [`NARRATION_STUB`] while `tool_calls` (and thus tool_use/tool_result pairing) are
297/// left intact; the original full text stays in `partitions.history`, so the projection reverses if
298/// the flag is turned off. `None` when the message isn't a collapsible narration turn or the flag is
299/// off. Caller restricts this to messages already past the protected recent window.
300fn project_assistant_narration(msg: &Message, enabled: bool) -> Option<Message> {
301    if !enabled || msg.role != Role::Assistant || msg.tool_calls.is_empty() {
302        return None;
303    }
304    let Content::Text(text) = &msg.content else {
305        return None;
306    };
307    if text == NARRATION_STUB || text.chars().count() < NARRATION_COLLAPSE_MIN_CHARS {
308        return None;
309    }
310    let mut projected = msg.clone();
311    projected.content = Content::Text(NARRATION_STUB.to_string());
312    projected.token_count = None; // recomputed against the smaller stub
313    Some(projected)
314}
315
316/// If any tool-result body is not resident in working context (`Collapsed` or `PagedOut`), return
317/// a projected copy with those parts previewed; `None` if nothing is projected.
318fn project_message(msg: &Message, handles: &HandleTable) -> Option<Message> {
319    let Content::Parts(parts) = &msg.content else {
320        return None;
321    };
322    let mut changed = false;
323    let new_parts: Vec<ContentPart> = parts
324        .iter()
325        .map(|part| match part {
326            ContentPart::ToolResult {
327                call_id,
328                output,
329                is_error,
330                durable_content,
331            } if matches!(
332                handles.residency_for_source(call_id),
333                Some(Residency::Collapsed | Residency::PagedOut { .. })
334            ) =>
335            {
336                changed = true;
337                ContentPart::ToolResult {
338                    call_id: call_id.clone(),
339                    output: collapse_preview(output, call_id.as_str()),
340                    is_error: *is_error,
341                    durable_content: durable_content.clone(),
342                }
343            }
344            other => other.clone(),
345        })
346        .collect();
347    if changed {
348        let mut projected = msg.clone();
349        projected.content = Content::Parts(new_parts);
350        projected.token_count = None; // recomputed against the smaller projected body
351        Some(projected)
352    } else {
353        None
354    }
355}
356
357/// Render the context into a `RenderedContext` suitable for a provider API call.
358///
359/// Equivalent to [`render_projected`] with an empty handle table (no Layer-4 projection) and no
360/// frozen-prefix boundary (`frozen_history_len = 0` → `frozen_prefix_len` is always `None`).
361/// Test convenience — the production path is `ContextManager::render` → [`render_projected`].
362#[cfg(test)]
363pub(crate) fn render(
364    partitions: &ContextPartitions,
365    budget: u32,
366    engine: &ContextTokenEngine,
367    preserve_recent_units: usize,
368) -> RenderedContext {
369    // The convenience wrapper renders history verbatim (no narration collapse) — callers that want
370    // Method-1 collapse drive `render_projected` with the flag (the kernel passes it from config).
371    render_projected(
372        partitions,
373        budget,
374        engine,
375        preserve_recent_units,
376        &HandleTable::new(),
377        0,
378        false,
379    )
380}
381
382/// Render with Layer-4 read-time projection driven by `handles`: tool results whose handle is
383/// `Collapsed` or `PagedOut` render as previews (originals untouched), freeing budget for more
384/// recent turns.
385///
386/// Token budget:
387///   system_stable + system_knowledge tokens are subtracted first.
388///   Remaining budget is allocated to history turns newest-first.
389///   The newest protected context units are always included.
390///   Every other context unit is included or dropped atomically.
391pub fn render_projected(
392    partitions: &ContextPartitions,
393    budget: u32,
394    engine: &ContextTokenEngine,
395    preserve_recent_units: usize,
396    handles: &HandleTable,
397    frozen_history_len: usize,
398    collapse_narration: bool,
399) -> RenderedContext {
400    let system_stable = build_system_stable(partitions);
401    let system_knowledge = build_system_knowledge(partitions);
402    let system_text = [system_stable.as_str(), system_knowledge.as_str()]
403        .iter()
404        .filter(|s| !s.is_empty())
405        .cloned()
406        .collect::<Vec<_>>()
407        .join("\n\n");
408
409    // Fixed context is accounted before history. Counting the real value (rather than clamping it
410    // to the budget) makes an impossible request observable instead of hiding the overage.
411    let system_tokens = engine.count(&system_text);
412    let state_turn = build_state_turn(partitions);
413    let state_tokens = state_turn
414        .as_ref()
415        .map_or(0, |message| engine.count_message(message));
416    let fixed_tokens = system_tokens.saturating_add(state_tokens);
417    let mut remaining = budget.saturating_sub(fixed_tokens);
418    let mut used_tokens = fixed_tokens;
419    let mut budget_overflow = (fixed_tokens > budget).then_some(ContextBudgetOverflow {
420        kind: ContextBudgetOverflowKind::FixedContext,
421        required_tokens: fixed_tokens,
422        max_tokens: budget,
423    });
424
425    let units = unit_boundaries(&partitions.history.messages);
426    let protected_from = units.len().saturating_sub(preserve_recent_units);
427    let mut kept_messages_rev = Vec::with_capacity(partitions.history.messages.len());
428    let mut kept_unit_ranges = Vec::with_capacity(units.len());
429
430    for (unit_index, unit) in units.iter().enumerate().rev() {
431        let is_protected = unit_index >= protected_from;
432        let unit_start = kept_messages_rev.len();
433        let mut tokens = 0u32;
434        for msg in &partitions.history.messages[unit.clone()] {
435            let effective = project_message(msg, handles)
436                .or_else(|| {
437                    if is_protected {
438                        None
439                    } else {
440                        project_assistant_narration(msg, collapse_narration)
441                    }
442                })
443                .unwrap_or_else(|| msg.clone());
444            tokens += effective
445                .token_count
446                .unwrap_or_else(|| engine.count_message(&effective));
447            kept_messages_rev.push(effective);
448        }
449        if tokens == 0 {
450            kept_messages_rev.truncate(unit_start);
451            continue;
452        }
453
454        if is_protected || tokens <= remaining {
455            kept_unit_ranges.push(unit_start..kept_messages_rev.len());
456            remaining = remaining.saturating_sub(tokens);
457            used_tokens = used_tokens.saturating_add(tokens);
458            if is_protected && used_tokens > budget && budget_overflow.is_none() {
459                budget_overflow = Some(ContextBudgetOverflow {
460                    kind: ContextBudgetOverflowKind::ProtectedTail,
461                    required_tokens: used_tokens,
462                    max_tokens: budget,
463                });
464            }
465        } else {
466            kept_messages_rev.truncate(unit_start);
467            break;
468        }
469    }
470
471    let mut turns = Vec::with_capacity(kept_messages_rev.len());
472    for unit in kept_unit_ranges.into_iter().rev() {
473        // Units were appended newest-first, so every reverse-ordered range is the current suffix.
474        // Draining suffixes restores chronological unit order without cloning messages or allocating
475        // one temporary Vec per unit.
476        turns.extend(kept_messages_rev.drain(unit));
477    }
478    normalize_turn_prefix(&mut turns);
479    debug_assert!(
480        !strict_tool_pairing_is_valid(&partitions.history.messages)
481            || strict_tool_pairing_is_valid(&turns),
482        "renderer split a valid tool transaction"
483    );
484
485    // P1-E: locate the frozen-prefix boundary in rendered turns. `frozen_history_len` is the
486    // history length as of the last compaction (0 before any) — messages beyond it are the hot
487    // tail that grows each turn. We count the hot tail from the END, which is robust to the leading
488    // anchor and to budget-dropping of OLD turns (the recent tail is never dropped). Emit `Some`
489    // only for a distinct, non-empty frozen region; otherwise providers use the rolling-pair
490    // fallback (deep == tail would waste a breakpoint).
491    let hot = partitions
492        .history
493        .messages
494        .len()
495        .saturating_sub(frozen_history_len);
496    let frozen_prefix_len = if frozen_history_len > 0 && hot > 0 && hot < turns.len() {
497        Some(turns.len() - hot)
498    } else {
499        None
500    };
501
502    RenderedContext {
503        system_text,
504        system_stable,
505        system_knowledge,
506        turns,
507        state_turn,
508        frozen_prefix_len,
509        budget_overflow,
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::context::config::ContextConfig;
517    use crate::context::partitions::ContextPartitions;
518    use crate::context::task_state::{PlanStep, TaskState};
519    use crate::context::token_engine::ContextTokenEngine;
520    use crate::types::message::{Message, Role};
521
522    fn engine() -> ContextTokenEngine {
523        ContextTokenEngine::char_approx()
524    }
525    fn ctx() -> ContextPartitions {
526        ContextPartitions::new(&ContextConfig::default())
527    }
528
529    #[test]
530    fn system_stable_contains_system_partition() {
531        let mut c = ctx();
532        c.system.push(Message::system("You are helpful."), 10);
533        let rc = render(&c, 10_000, &engine(), 4);
534        assert!(rc.system_stable.contains("You are helpful."));
535        assert!(rc.system_text.contains("You are helpful."));
536    }
537
538    #[test]
539    fn system_knowledge_contains_knowledge_partition() {
540        let mut c = ctx();
541        c.knowledge.push(Message::system("skill: debug"), 10);
542        let rc = render(&c, 10_000, &engine(), 4);
543        assert!(rc.system_knowledge.contains("skill: debug"));
544        assert!(rc.system_text.contains("skill: debug"));
545    }
546
547    #[test]
548    fn task_state_appears_in_state_turn() {
549        let mut c = ctx();
550        c.task_state = TaskState {
551            goal: "find the bug".to_string(),
552            ..Default::default()
553        };
554        let rc = render(&c, 10_000, &engine(), 4);
555        assert!(
556            !rc.system_text.contains("[TASK STATE]"),
557            "task_state must not be in system_text"
558        );
559        let state = rc.state_turn.as_ref().expect("should have a state turn");
560        assert_eq!(state.role, Role::User);
561        assert!(
562            state
563                .content
564                .as_text()
565                .unwrap()
566                .contains("[TASK STATE] goal: find the bug")
567        );
568        // State is NOT in the cacheable history turns.
569        assert!(!rc.turns.iter().any(|m| {
570            m.content
571                .as_text()
572                .map(|t| t.contains("[TASK STATE]"))
573                .unwrap_or(false)
574        }));
575    }
576
577    #[test]
578    fn signals_appear_in_state_turn() {
579        let mut c = ctx();
580        c.task_state = TaskState {
581            goal: "g".to_string(),
582            ..Default::default()
583        };
584        c.signals.push("[ROLLBACK] tool failed".to_string());
585        let rc = render(&c, 10_000, &engine(), 4);
586        let state = rc.state_turn.as_ref().unwrap();
587        assert!(
588            state
589                .content
590                .as_text()
591                .unwrap()
592                .contains("[ROLLBACK] tool failed")
593        );
594    }
595
596    #[test]
597    fn empty_task_state_no_state_turn() {
598        let c = ctx();
599        let rc = render(&c, 10_000, &engine(), 4);
600        // No state turn when task_state is empty and no signals
601        assert!(rc.state_turn.is_none());
602        assert!(rc.turns.is_empty());
603    }
604
605    #[test]
606    fn history_excludes_state_turn() {
607        let mut c = ctx();
608        c.task_state = TaskState {
609            goal: "g".to_string(),
610            ..Default::default()
611        };
612        c.history.push(Message::user("step 1"), 5);
613        c.history.push(Message::assistant("done"), 5);
614        let rc = render(&c, 10_000, &engine(), 4);
615        // turns is history only; state lives in state_turn.
616        assert!(
617            rc.state_turn
618                .as_ref()
619                .unwrap()
620                .content
621                .as_text()
622                .unwrap()
623                .contains("[TASK STATE]")
624        );
625        assert_eq!(rc.turns[0].role, Role::User);
626        assert_eq!(rc.turns[0].content.as_text(), Some("step 1"));
627        assert_eq!(rc.turns[1].role, Role::Assistant);
628    }
629
630    #[test]
631    fn all_assistant_tool_history_gets_anchor_user_turn() {
632        let mut c = ctx();
633        c.history.push(Message::assistant("reply"), 5);
634        let rc = render(&c, 10_000, &engine(), 4);
635        assert_eq!(rc.turns[0].role, Role::User);
636    }
637
638    #[test]
639    fn zero_token_messages_skipped() {
640        let mut c = ctx();
641        c.history.push(Message::user("zero"), 0);
642        c.history.push(Message::user("real"), 5);
643        let rc = render(&c, 10_000, &engine(), 4);
644        // Only "real" in history turns (state turn absent — no task_state)
645        assert!(rc.turns.iter().any(|m| m.content.as_text() == Some("real")));
646        assert!(!rc.turns.iter().any(|m| m.content.as_text() == Some("zero")));
647    }
648
649    #[test]
650    fn collapsed_tool_result_renders_as_preview_without_mutating_history() {
651        use crate::mm::handle::{Handle, HandleKind, HandleTable, Residency};
652
653        let mut c = ctx();
654        let long = "DATA ".repeat(200); // 1000 bytes
655        c.history.push(
656            Message::tool(vec![ContentPart::ToolResult {
657                call_id: "c1".into(),
658                output: long.clone(),
659                is_error: false,
660                durable_content: None,
661            }]),
662            250,
663        );
664
665        let mut handles = HandleTable::new();
666        let mut h = Handle::resident_for(1, HandleKind::ToolResult, 250, "c1");
667        h.residency = Residency::Collapsed;
668        handles.insert(h);
669
670        let rc = render_projected(&c, 10_000, &engine(), 4, &handles, 0, false);
671        let rendered: String = rc
672            .turns
673            .iter()
674            .flat_map(|m| match &m.content {
675                Content::Parts(parts) => parts.clone(),
676                _ => Vec::new(),
677            })
678            .find_map(|p| match p {
679                ContentPart::ToolResult { output, .. } => Some(output),
680                _ => None,
681            })
682            .expect("tool result rendered");
683        // Rendered copy is a preview with the trained truncation phrasing + retrieval instruction.
684        assert!(rendered.contains("[Output truncated:"));
685        assert!(rendered.contains("read_result"));
686        assert!(rendered.contains("\"c1\""));
687        assert!(rendered.len() < long.len());
688        let stored = match &c.history.messages[0].content {
689            Content::Parts(parts) => match &parts[0] {
690                ContentPart::ToolResult { output, .. } => output.clone(),
691                _ => unreachable!(),
692            },
693            _ => unreachable!(),
694        };
695        assert_eq!(stored, long, "projection must not mutate stored history");
696    }
697
698    #[test]
699    fn paged_out_tool_result_uses_the_same_projection_as_collapsed() {
700        use crate::mm::handle::{Handle, HandleKind, HandleTable, Residency};
701
702        let mut c = ctx();
703        let long = "ARCHIVED ".repeat(200);
704        c.history.push(
705            Message::tool(vec![ContentPart::ToolResult {
706                call_id: "c-paged".into(),
707                output: long,
708                is_error: false,
709                durable_content: None,
710            }]),
711            300,
712        );
713
714        let render_with = |residency| {
715            let mut handles = HandleTable::new();
716            let mut handle = Handle::resident_for(1, HandleKind::ToolResult, 300, "c-paged");
717            handle.residency = residency;
718            handles.insert(handle);
719            render_projected(&c, 10_000, &engine(), 4, &handles, 0, false)
720        };
721        let collapsed = render_with(Residency::Collapsed);
722        let paged_out = render_with(Residency::PagedOut {
723            payload_ref: "payload:archive-c-paged".to_string(),
724            digest: format!("sha256:{}", "1".repeat(64)),
725        });
726
727        assert_eq!(
728            serde_json::to_value(paged_out.turns).unwrap(),
729            serde_json::to_value(collapsed.turns).unwrap(),
730            "PagedOut and Collapsed both retain only the read_result preview in working context",
731        );
732    }
733
734    #[test]
735    fn resident_tool_result_renders_in_full() {
736        use crate::mm::handle::{Handle, HandleKind, HandleTable};
737
738        let mut c = ctx();
739        let body = "RESIDENT BODY ".repeat(20);
740        c.history.push(
741            Message::tool(vec![ContentPart::ToolResult {
742                call_id: "c2".into(),
743                output: body.clone(),
744                is_error: false,
745                durable_content: None,
746            }]),
747            60,
748        );
749        let mut handles = HandleTable::new();
750        handles.insert(Handle::resident_for(1, HandleKind::ToolResult, 60, "c2"));
751
752        let rc = render_projected(&c, 10_000, &engine(), 4, &handles, 0, false);
753        let rendered: String = rc
754            .turns
755            .iter()
756            .flat_map(|m| match &m.content {
757                Content::Parts(parts) => parts.clone(),
758                _ => Vec::new(),
759            })
760            .find_map(|p| match p {
761                ContentPart::ToolResult { output, .. } => Some(output),
762                _ => None,
763            })
764            .expect("tool result rendered");
765        assert_eq!(rendered, body);
766        assert!(!rendered.contains("[Output truncated:"));
767    }
768
769    // ── P1-F: state-turn recency footer ───────────────────────────────────
770
771    #[test]
772    fn state_turn_footer_leads_with_next_step_not_bare_goal() {
773        let mut c = ctx();
774        c.task_state = TaskState {
775            goal: "ship the cache work".to_string(),
776            plan: vec![PlanStep {
777                label: "do E".to_string(),
778                done: false,
779            }],
780            current_step: Some(0),
781            ..Default::default()
782        };
783        c.task_state.record_directive("don't break ABI");
784        let rc = render(&c, 100_000, &engine(), 4);
785        let text = rc
786            .state_turn
787            .unwrap()
788            .content
789            .as_text()
790            .unwrap()
791            .to_string();
792
793        // The full TASK STATE block still LEADS (primacy) — goal-adherence preserved ...
794        assert!(text.starts_with("[TASK STATE] goal: ship the cache work"));
795        // ... but the peak-attention footer leads with the forward action, not a goal restatement.
796        let before_proceed = text
797            .rsplit_once("\n\nProceed.")
798            .expect("ends with Proceed")
799            .0;
800        let last_block = before_proceed.rsplit("\n\n").next().unwrap();
801        assert!(
802            last_block.starts_with("→ next: step 1 — do E"),
803            "got: {last_block}"
804        );
805        assert!(last_block.contains("must: don't break ABI"));
806        // The bare goal must NOT be re-injected at the peak-attention tail (the repetition fuel).
807        assert!(
808            !last_block.contains("focus: ship the cache work"),
809            "got: {last_block}"
810        );
811    }
812
813    #[test]
814    fn footer_falls_back_to_focus_goal_when_nothing_done_yet() {
815        // Turn 1: no actions, no plan — the footer surfaces the goal so the model knows the objective.
816        let mut c = ctx();
817        c.task_state = TaskState {
818            goal: "build the thing".to_string(),
819            ..Default::default()
820        };
821        let rc = render(&c, 100_000, &engine(), 4);
822        let text = rc
823            .state_turn
824            .unwrap()
825            .content
826            .as_text()
827            .unwrap()
828            .to_string();
829        let footer = text
830            .rsplit_once("\n\nProceed.")
831            .unwrap()
832            .0
833            .rsplit("\n\n")
834            .next()
835            .unwrap();
836        assert_eq!(footer, "→ focus: build the thing");
837    }
838
839    #[test]
840    fn footer_shows_recent_actions_and_forward_nudge_without_a_plan() {
841        // No curated plan, but real tool activity (2b) → the footer shows motion + a forward nudge,
842        // and the goal is NOT restated at the tail.
843        let mut c = ctx();
844        c.task_state = TaskState {
845            goal: "rebuild §4.4 as SVG".to_string(),
846            ..Default::default()
847        };
848        c.task_state.note_actions("module_list");
849        c.task_state.note_actions("module_read");
850        let rc = render(&c, 100_000, &engine(), 4);
851        let footer = rc
852            .state_turn
853            .unwrap()
854            .content
855            .as_text()
856            .unwrap()
857            .rsplit_once("\n\nProceed.")
858            .unwrap()
859            .0
860            .rsplit("\n\n")
861            .next()
862            .unwrap()
863            .to_string();
864        assert!(
865            footer.contains("did: module_list → module_read"),
866            "got: {footer}"
867        );
868        assert!(footer.contains("next: advance the goal"), "got: {footer}");
869        assert!(
870            !footer.contains("focus: rebuild §4.4 as SVG"),
871            "goal must not lead the footer"
872        );
873    }
874
875    #[test]
876    fn footer_raises_stop_on_repeated_action() {
877        // The same action on the last ≥2 turns ⇒ explicit STOP backstop (breaks the read-loop in-band).
878        let mut c = ctx();
879        c.task_state = TaskState {
880            goal: "g".to_string(),
881            ..Default::default()
882        };
883        c.task_state.note_actions("document_read");
884        c.task_state.note_actions("document_read");
885        c.task_state.note_actions("document_read");
886        let rc = render(&c, 100_000, &engine(), 4);
887        let footer = rc
888            .state_turn
889            .unwrap()
890            .content
891            .as_text()
892            .unwrap()
893            .rsplit_once("\n\nProceed.")
894            .unwrap()
895            .0
896            .rsplit("\n\n")
897            .next()
898            .unwrap()
899            .to_string();
900        assert!(
901            footer.contains("STOP: `document_read` repeated 3×"),
902            "got: {footer}"
903        );
904    }
905
906    #[test]
907    fn no_salience_footer_without_a_goal() {
908        let mut c = ctx();
909        c.signals.push("[ROLLBACK] tool failed".to_string());
910        let rc = render(&c, 100_000, &engine(), 4);
911        let text = rc
912            .state_turn
913            .unwrap()
914            .content
915            .as_text()
916            .unwrap()
917            .to_string();
918        assert!(!text.contains("→ focus:"), "no goal ⇒ no footer");
919        // signals remain the last content before the anchor.
920        assert!(text.contains("[ROLLBACK] tool failed"));
921    }
922
923    // ── P0-A: prefix fingerprint (cache-drift instrument) ──────────────────
924
925    #[test]
926    fn prefix_fingerprint_is_stable_when_appending_history() {
927        let mut c = ctx();
928        c.system.push(Message::system("rules"), 5);
929        c.knowledge.push(Message::system("skill: debug"), 5);
930        c.history.push(Message::user("turn A"), 5);
931        c.history.push(Message::assistant("turn B"), 5);
932        let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
933
934        // Append a new turn — the existing prefix must stay byte-identical.
935        c.history.push(Message::user("turn C"), 5);
936        let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
937
938        assert!(
939            fp2.extends(&fp1),
940            "appending must only grow the tail, never drift the prefix"
941        );
942        assert_eq!(
943            fp2.common_turn_prefix(&fp1),
944            2,
945            "both prior turns stay cache-reusable"
946        );
947        assert_eq!(fp2.turn_hashes.len(), 3);
948    }
949
950    #[test]
951    fn prefix_fingerprint_ignores_state_turn() {
952        // Same history, different task_state/signals → the cacheable prefix is
953        // identical (state lives in the uncached tail, out of `turns`).
954        let mut c = ctx();
955        c.history.push(Message::user("turn A"), 5);
956        c.task_state = TaskState {
957            goal: "first goal".to_string(),
958            ..Default::default()
959        };
960        let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
961
962        c.task_state = TaskState {
963            goal: "totally different goal".to_string(),
964            ..Default::default()
965        };
966        c.signals.push("[ROLLBACK] whatever".to_string());
967        let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
968
969        assert_eq!(
970            fp1, fp2,
971            "volatile state must not perturb the cacheable prefix"
972        );
973    }
974
975    #[test]
976    fn prefix_fingerprint_detects_system_drift() {
977        let mut c = ctx();
978        c.system.push(Message::system("original rules"), 5);
979        c.history.push(Message::user("turn A"), 5);
980        let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
981
982        c.system.messages.clear();
983        c.system.push(Message::system("updated rules"), 5);
984        let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
985
986        assert_ne!(fp1.system_stable_hash, fp2.system_stable_hash);
987        assert!(
988            !fp2.extends(&fp1),
989            "a system-block edit invalidates the whole prefix"
990        );
991    }
992
993    #[test]
994    fn prefix_fingerprint_detects_in_place_collapse_churn() {
995        use crate::mm::handle::{Handle, HandleKind, HandleTable, Residency};
996
997        let mut c = ctx();
998        c.history.push(Message::user("start"), 5);
999        let long = "DATA ".repeat(200);
1000        c.history.push(
1001            Message::tool(vec![ContentPart::ToolResult {
1002                call_id: "c1".into(),
1003                output: long,
1004                is_error: false,
1005                durable_content: None,
1006            }]),
1007            250,
1008        );
1009        c.history.push(Message::user("recent"), 5);
1010
1011        let resident = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
1012
1013        // Collapsing the old tool result rewrites that turn in place → the prefix
1014        // hash at that position changes (the cache-cost of folding, made visible).
1015        let mut handles = HandleTable::new();
1016        let mut h = Handle::resident_for(1, HandleKind::ToolResult, 250, "c1");
1017        h.residency = Residency::Collapsed;
1018        handles.insert(h);
1019        let collapsed =
1020            render_projected(&c, 100_000, &engine(), 4, &handles, 0, false).prefix_fingerprint();
1021
1022        // turn 0 ("start") is byte-stable; the collapsed tool result at turn 1 drifts.
1023        assert_eq!(
1024            collapsed.common_turn_prefix(&resident),
1025            1,
1026            "drift begins at the collapsed turn"
1027        );
1028        assert!(!collapsed.extends(&resident));
1029    }
1030
1031    // ── Method 1: assistant-narration collapse ─────────────────────────────
1032
1033    fn assistant_with_call(text: &str) -> Message {
1034        let mut m = Message::assistant(text);
1035        m.tool_calls = vec![crate::types::message::ToolCall {
1036            id: "c1".into(),
1037            name: "module_read".into(),
1038            arguments: serde_json::json!({}),
1039        }];
1040        m
1041    }
1042
1043    #[test]
1044    fn old_assistant_narration_collapses_keeping_tool_calls() {
1045        let mut c = ctx();
1046        // Oldest = a long preamble + a tool call; then enough recent turns to push it past the window.
1047        c.history.push(assistant_with_call(&"好的,我来将 §4.4 的 Mermaid 部署架构图重新构建为 SVG 版本。先找到当前 Mermaid 模块的位置。".repeat(1)), 60);
1048        c.history.push(
1049            Message::tool(vec![ContentPart::ToolResult {
1050                call_id: "c1".into(),
1051                output: "located".into(),
1052                is_error: false,
1053                durable_content: None,
1054            }]),
1055            2,
1056        );
1057        for i in 0..5 {
1058            c.history.push(Message::user(format!("recent {i}")), 5);
1059        }
1060
1061        // collapse ON (preserve window = 4, so the oldest narration turn is past it)
1062        let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
1063        let narration = rc
1064            .turns
1065            .iter()
1066            .find(|m| m.content.as_text() == Some(NARRATION_STUB))
1067            .expect("old narration replaced by stub");
1068        assert_eq!(
1069            narration.tool_calls.len(),
1070            1,
1071            "tool call (pairing) preserved"
1072        );
1073        assert_eq!(narration.tool_calls[0].name, "module_read");
1074        // No verbatim preamble survives in the rendered prefix.
1075        assert!(!rc.turns.iter().any(|m| {
1076            m.content
1077                .as_text()
1078                .map(|t| t.contains("先找到当前 Mermaid"))
1079                .unwrap_or(false)
1080        }));
1081        // Original history is untouched (non-destructive projection).
1082        assert!(
1083            c.history.messages[0]
1084                .content
1085                .as_text()
1086                .unwrap()
1087                .contains("先找到当前 Mermaid")
1088        );
1089
1090        // collapse OFF → verbatim narration survives.
1091        let rc_off = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, false);
1092        assert!(rc_off.turns.iter().any(|m| {
1093            m.content
1094                .as_text()
1095                .map(|t| t.contains("先找到当前 Mermaid"))
1096                .unwrap_or(false)
1097        }));
1098    }
1099
1100    #[test]
1101    fn recent_assistant_narration_within_window_is_not_collapsed() {
1102        let mut c = ctx();
1103        // Only 2 turns, preserve window = 4 → the narration turn is protected → never collapsed.
1104        c.history.push(
1105            assistant_with_call(
1106                &"好的,我来将 §4.4 重新构建为 SVG。先定位模块位置确认范围读取内容。".to_string(),
1107            ),
1108            60,
1109        );
1110        c.history.push(
1111            Message::tool(vec![ContentPart::ToolResult {
1112                call_id: "c1".into(),
1113                output: "located".into(),
1114                is_error: false,
1115                durable_content: None,
1116            }]),
1117            2,
1118        );
1119        c.history.push(Message::user("ok"), 5);
1120        let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
1121        assert!(
1122            rc.turns.iter().any(|m| m
1123                .content
1124                .as_text()
1125                .map(|t| t.contains("先定位模块位置"))
1126                .unwrap_or(false)),
1127            "recent narration kept verbatim"
1128        );
1129    }
1130
1131    #[test]
1132    fn assistant_without_tool_calls_is_never_collapsed() {
1133        let mut c = ctx();
1134        // A pure final answer (no tool calls) is substantive — must survive even when old.
1135        c.history.push(
1136            Message::assistant("这是给用户的最终结论,包含实质内容,不应被折叠掉以免丢信息。"),
1137            40,
1138        );
1139        for i in 0..5 {
1140            c.history.push(Message::user(format!("r{i}")), 5);
1141        }
1142        let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
1143        assert!(
1144            rc.turns.iter().any(|m| m
1145                .content
1146                .as_text()
1147                .map(|t| t.contains("最终结论"))
1148                .unwrap_or(false)),
1149            "answer-only turns are not narration"
1150        );
1151    }
1152
1153    #[test]
1154    fn collapsing_narration_drifts_only_that_turn_in_the_cache_prefix() {
1155        // The cost made visible: collapsing rewrites that one turn in place → the prefix hash drifts
1156        // at its position (one-time, as it ages past the window), but earlier turns stay reusable.
1157        let mut c = ctx();
1158        c.history.push(Message::user("start"), 5);
1159        c.history.push(assistant_with_call(&"好的,我来将 §4.4 重新构建为 SVG 版本。先找到 Mermaid 模块的确切位置再读取其内容。".to_string()), 60);
1160        c.history.push(
1161            Message::tool(vec![ContentPart::ToolResult {
1162                call_id: "c1".into(),
1163                output: "located".into(),
1164                is_error: false,
1165                durable_content: None,
1166            }]),
1167            2,
1168        );
1169        for i in 0..4 {
1170            c.history.push(Message::user(format!("recent {i}")), 5);
1171        }
1172
1173        let verbatim = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, false)
1174            .prefix_fingerprint();
1175        let collapsed = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true)
1176            .prefix_fingerprint();
1177        // turn 0 ("start") is byte-stable; drift begins at the collapsed narration turn (index 1).
1178        assert_eq!(
1179            collapsed.common_turn_prefix(&verbatim),
1180            1,
1181            "only the collapsed turn drifts"
1182        );
1183        assert!(!collapsed.extends(&verbatim));
1184    }
1185
1186    #[test]
1187    fn protected_recent_messages_kept_whole_over_budget() {
1188        let mut c = ctx();
1189        c.history.push(Message::user("first message"), 5);
1190        c.history.push(Message::user("a".repeat(1000)), 250);
1191        // Two protected context units are kept whole regardless of the 10-token budget.
1192        let rc = render(&c, 10, &engine(), 2);
1193        assert!(rc.turns.iter().any(|m| {
1194            m.content
1195                .as_text()
1196                .map(|t| t.contains("first message"))
1197                .unwrap_or(false)
1198        }));
1199    }
1200
1201    #[test]
1202    fn render_drops_or_keeps_tool_transactions_as_complete_units() {
1203        let mut c = ctx();
1204        c.history.push(Message::user("old"), 10);
1205        c.history.push(Message::assistant("old answer"), 10);
1206        let mut call = Message::assistant("calling");
1207        call.tool_calls.push(crate::types::message::ToolCall {
1208            id: "call-1".into(),
1209            name: "read".into(),
1210            arguments: serde_json::json!({}),
1211        });
1212        c.history.push(Message::user("question"), 10);
1213        c.history.push(call, 10);
1214        c.history.push(
1215            Message::tool(vec![crate::types::message::ContentPart::ToolResult {
1216                call_id: "call-1".into(),
1217                output: "ok".into(),
1218                is_error: false,
1219                durable_content: None,
1220            }]),
1221            10,
1222        );
1223        c.history.push(Message::assistant("answer"), 10);
1224
1225        let rc = render(&c, 25, &engine(), 1);
1226
1227        assert_eq!(rc.turns.len(), 4);
1228        assert_eq!(rc.turns[0].content.as_text(), Some("question"));
1229        assert_eq!(rc.turns[3].content.as_text(), Some("answer"));
1230    }
1231
1232    #[test]
1233    fn oversized_text_boundary_is_dropped_whole_not_truncated() {
1234        // P0-B1: an unprotected, over-budget Text boundary message is dropped whole — never
1235        // mid-truncated — so no budget-dependent fragment lands in the cached prefix.
1236        let mut c = ctx();
1237        c.history.push(Message::user("a".repeat(1000)), 250); // oldest, oversized
1238        c.history.push(Message::user("recent"), 2); // newest, fits
1239        let rc = render(&c, 5, &engine(), 0); // nothing protected
1240        assert_eq!(rc.turns.len(), 1, "only the fitting newest turn survives");
1241        assert_eq!(rc.turns[0].content.as_text(), Some("recent"));
1242        assert!(
1243            !rc.turns.iter().any(|m| m
1244                .content
1245                .as_text()
1246                .map(|t| t.starts_with("aaaa"))
1247                .unwrap_or(false)),
1248            "no truncated body in the prefix"
1249        );
1250    }
1251
1252    #[test]
1253    fn state_turn_is_budgeted_before_history() {
1254        let mut c = ctx();
1255        c.task_state = TaskState {
1256            goal: "keep the state".to_string(),
1257            ..Default::default()
1258        };
1259        c.history.push(Message::user("x".repeat(120)), 30);
1260        let state_tokens = engine().count_message(&build_state_turn(&c).expect("state"));
1261
1262        let rc = render(&c, state_tokens + 5, &engine(), 0);
1263
1264        assert!(
1265            rc.turns.is_empty(),
1266            "history must not consume the state reservation"
1267        );
1268        assert!(rc.budget_overflow.is_none());
1269    }
1270
1271    #[test]
1272    fn protected_tail_overflow_is_reported_instead_of_hidden() {
1273        let mut c = ctx();
1274        c.history.push(Message::user("x".repeat(400)), 100);
1275
1276        let rc = render(&c, 10, &engine(), 2);
1277
1278        let overflow = rc
1279            .budget_overflow
1280            .expect("protected tail overflow must be explicit");
1281        assert_eq!(overflow.kind, ContextBudgetOverflowKind::ProtectedTail);
1282        assert!(overflow.required_tokens > overflow.max_tokens);
1283    }
1284
1285    #[test]
1286    fn fixed_context_overflow_is_reported_with_actual_token_count() {
1287        let mut c = ctx();
1288        c.system.push(Message::system("x".repeat(400)), 100);
1289
1290        let rc = render(&c, 10, &engine(), 0);
1291
1292        let overflow = rc
1293            .budget_overflow
1294            .expect("fixed context overflow must be explicit");
1295        assert_eq!(overflow.kind, ContextBudgetOverflowKind::FixedContext);
1296        assert_eq!(overflow.required_tokens, 100);
1297        assert_eq!(overflow.max_tokens, 10);
1298    }
1299}