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