Skip to main content

deepstrike_core/context/
renderer.rs

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