Skip to main content

deepstrike_core/context/
renderer.rs

1use super::partitions::ContextPartitions;
2#[cfg(test)]
3use super::fault::stable_hash;
4use super::task_state::TaskState;
5use super::token_engine::ContextTokenEngine;
6use crate::mm::handle::{HandleTable, Residency};
7use crate::types::message::{Content, ContentPart, Message, Role};
8use serde::{Deserialize, Serialize};
9
10/// Structured render output aligned with LLM API slots.
11///
12/// Slot 1 — system_stable:    Identity (system partition). Anthropic system[0] cache_control.
13/// Slot 2 — system_knowledge: Knowledge partition. Anthropic system[1] cache_control.
14/// Slot 3 — turns[0..N]:      History turns (stable, cacheable prefix).
15/// Slot 4 — state_turn:       State (task_state + signals), rebuilt every call.
16///
17/// The State turn is kept OUT of `turns` so the history prefix stays byte-stable
18/// across turns and can be prompt-cached. Providers place `state_turn` themselves:
19/// Anthropic appends it AFTER the message-history cache breakpoint (so the volatile
20/// state is the cheap uncached tail); OpenAI-family prepend it (preserving today's
21/// ordering). When this struct is produced by an older binding that has not been
22/// rebuilt, `state_turn` is absent and `turns[0]` still carries the State turn —
23/// providers handle both shapes.
24///
25/// system_text = system_stable + system_knowledge (for OpenAI which has one system slot).
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RenderedContext {
28    /// Identity + Knowledge combined — for providers with a single system slot (OpenAI).
29    pub system_text: String,
30    /// Identity only (system partition). Anthropic system[0] with cache_control.
31    pub system_stable: String,
32    /// Knowledge (memory retrievals, skill definitions, artifacts). Anthropic system[1] with cache_control.
33    pub system_knowledge: String,
34    /// History turns only — the stable, cacheable message prefix.
35    pub turns: Vec<Message>,
36    /// Volatile State turn (task_state + signals), rebuilt every call. Rendered
37    /// after the cacheable history. `None` when there is no task state or signals.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub state_turn: Option<Message>,
40    /// P1-E: number of leading `turns` that form the **frozen prefix** — byte-stable until the
41    /// next compaction. Providers that place explicit cache breakpoints (Anthropic) pin one *deep*
42    /// breakpoint at this boundary (a long-lived cache that survives many turns and is immune to
43    /// the 20-block lookback miss on heavy tool turns) and roll the other at the tail. `None` when
44    /// there is no distinct frozen region yet (pre-first-compaction, or the whole render is hot) —
45    /// providers then fall back to the rolling-pair placement. Providers clamp out-of-range values.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub frozen_prefix_len: Option<usize>,
48}
49
50/// Per-render fingerprint of the **cacheable prefix** — the segments a provider
51/// caches as a stable prefix (system blocks + history `turns`). Excludes
52/// `state_turn` (the volatile uncached tail) and `token_count` metadata (not on the
53/// wire). This is the metrics-first instrument (P0-A) behind the optimization work:
54/// two renders share a reusable KV / prompt-cache prefix iff their system hashes
55/// match *and* one's `turn_hashes` is a prefix of the other's. Pure and derived —
56/// never stored in snapshots, session logs, or event logs.
57#[cfg(test)]
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub(crate) struct PrefixFingerprint {
60    pub system_stable_hash: u64,
61    pub system_knowledge_hash: u64,
62    /// One stable hash per history turn, in order. The longest common prefix with a
63    /// previous render's vector = how many turns stay cache-reusable across the call.
64    pub turn_hashes: Vec<u64>,
65}
66
67#[cfg(test)]
68impl PrefixFingerprint {
69    /// True when `self`'s cacheable prefix is a byte-stable *extension* of `prev`:
70    /// identical system segments and `prev.turn_hashes` is a prefix of
71    /// `self.turn_hashes`. This is exactly the KV / prompt-cache reuse condition —
72    /// no drift anywhere in the prefix, only growth at the tail.
73    pub(crate) fn extends(&self, prev: &PrefixFingerprint) -> bool {
74        self.system_stable_hash == prev.system_stable_hash
75            && self.system_knowledge_hash == prev.system_knowledge_hash
76            && prev.turn_hashes.len() <= self.turn_hashes.len()
77            && self.turn_hashes[..prev.turn_hashes.len()] == prev.turn_hashes[..]
78    }
79
80    /// Number of leading turns byte-identical to `prev` — the reusable turn-prefix
81    /// length. A drop below `prev.turn_hashes.len()` signals mid-prefix churn (a
82    /// turn rewritten in place, e.g. an in-place collapse) that invalidates cache.
83    pub(crate) fn common_turn_prefix(&self, prev: &PrefixFingerprint) -> usize {
84        self.turn_hashes
85            .iter()
86            .zip(prev.turn_hashes.iter())
87            .take_while(|(a, b)| a == b)
88            .count()
89    }
90}
91
92/// Wire-relevant hash of one turn: role + content + tool_calls, **excluding**
93/// `token_count` (kernel-only metadata that never reaches the provider). Serialised
94/// through serde so every content variant and tool-call argument is covered with a
95/// deterministic field order.
96#[cfg(test)]
97fn hash_turn(msg: &Message) -> u64 {
98    let material =
99        serde_json::to_vec(&(&msg.role, &msg.content, &msg.tool_calls)).unwrap_or_default();
100    stable_hash(&material)
101}
102
103#[cfg(test)]
104impl RenderedContext {
105    /// Compute the [`PrefixFingerprint`] for this render. See its docs for the
106    /// cache-reuse contract it certifies.
107    pub(crate) fn prefix_fingerprint(&self) -> PrefixFingerprint {
108        PrefixFingerprint {
109            system_stable_hash: stable_hash(self.system_stable.as_bytes()),
110            system_knowledge_hash: stable_hash(self.system_knowledge.as_bytes()),
111            turn_hashes: self.turns.iter().map(hash_turn).collect(),
112        }
113    }
114}
115
116fn build_system_stable(partitions: &ContextPartitions) -> String {
117    partitions.system.messages
118        .iter()
119        .filter_map(|m| m.content.as_text())
120        .collect::<Vec<_>>()
121        .join("\n\n")
122}
123
124fn build_system_knowledge(partitions: &ContextPartitions) -> String {
125    partitions.knowledge.messages()
126        .filter_map(|m| m.content.as_text())
127        .collect::<Vec<_>>()
128        .join("\n\n")
129}
130
131/// P1-F (+ 2b/2c): a one-line recency footer at the *last* content before the "Proceed." anchor —
132/// the highest-attention position in the prompt (the model attends most to the final tokens).
133///
134/// It LEADS WITH FORWARD MOTION (what just happened · what to do next · the standing directive), not
135/// a verbatim restatement of the goal. Re-injecting the bare goal at this peak-attention slot every
136/// turn primes the model to *re-narrate intent* ("好的,我来将<goal>…") instead of acting — an
137/// undamped repetition trap when there is no plan/progress to advance. The full goal still LEADS the
138/// TASK STATE block above (primacy + reference), so goal-adherence is preserved; the footer restates
139/// the goal only when nothing has happened yet (e.g. turn 1, no actions). `None` when there is no goal.
140///
141/// The "just did" clause is kernel-derived from `recent_actions` (real tool activity), and a trailing
142/// run of an identical action raises an explicit STOP — a cheap no-progress backstop that breaks the
143/// read→re-read→re-narrate loop in-band, at the position the model weights most.
144fn salience_footer(ts: &TaskState) -> Option<String> {
145    if ts.goal.is_empty() {
146        return None;
147    }
148    let mut clauses: Vec<String> = Vec::new();
149
150    // What just happened — display tool NAMES only. The full `name(args)` signatures are kept in
151    // `recent_actions` for the repeat check below, but rendering them every turn bloats the volatile
152    // footer; the names alone show motion at the peak-attention slot.
153    let recent = ts.recent_actions.as_slice();
154    let action_name = |entry: &str| entry.split('(').next().unwrap_or(entry).to_string();
155    if let Some(last) = recent.last() {
156        let start = recent.len().saturating_sub(3);
157        let names = recent[start..].iter().map(|e| action_name(e)).collect::<Vec<_>>().join(" → ");
158        clauses.push(format!("did: {names}"));
159
160        // No-progress backstop: the SAME call — name AND args — repeated on the last ≥2 turns is a
161        // stall (a legit loop varies its args, so it reads as distinct progress, not a repeat).
162        let trailing_repeat = recent.iter().rev().take_while(|a| *a == last).count();
163        if trailing_repeat >= 2 {
164            clauses.push(format!(
165                "STOP: `{}` repeated {trailing_repeat}× unchanged — do something different or report",
166                action_name(last)
167            ));
168        }
169    }
170
171    // What to do next — the active plan step if the model maintains one, else a short forward nudge.
172    let active_step = ts
173        .current_step
174        .and_then(|i| ts.plan.get(i).map(|s| (i, s)))
175        .filter(|(_, s)| !s.done);
176    if let Some((i, step)) = active_step {
177        clauses.push(format!("next: step {} — {}", i + 1, step.label));
178    } else if !recent.is_empty() {
179        clauses.push("next: advance the goal".to_string());
180    }
181
182    if let Some(d) = ts.directives.last() {
183        clauses.push(format!("must: {d}"));
184    }
185
186    // Lead with the goal only when no forward clause fills the footer (turn 1, nothing done yet);
187    // otherwise the forward clauses carry the salience and the goal stays in the block above.
188    let body = if clauses.is_empty() {
189        format!("→ focus: {}", ts.goal)
190    } else {
191        format!("→ {}", clauses.join(" · "))
192    };
193    Some(body)
194}
195
196/// Build the State turn (the volatile tail): task_state + signals + a recency focus footer +
197/// "Proceed." anchor. The footer sits last (just before "Proceed.") so the current goal/step/
198/// directive land in the prompt's highest-attention position (P1-F).
199fn build_state_turn(partitions: &ContextPartitions) -> Option<Message> {
200    let task = partitions.task_state.format_compact();
201    if task.is_empty() && partitions.signals.is_empty() {
202        return None;
203    }
204    let mut parts: Vec<String> = Vec::new();
205    if !task.is_empty() {
206        parts.push(task);
207    }
208    let signals_text = partitions.signals.join("\n");
209    if !signals_text.is_empty() {
210        parts.push(signals_text);
211    }
212    if let Some(footer) = salience_footer(&partitions.task_state) {
213        parts.push(footer);
214    }
215    let body = parts.join("\n\n");
216    Some(Message::user(format!("{body}\n\nProceed.")))
217}
218
219/// Ensure turns start with a user message.
220/// After AutoCompact the preserved tail may be all assistant/tool — insert an anchor.
221fn normalize_turn_prefix(turns: &mut Vec<Message>) {
222    if !turns.is_empty() && matches!(turns[0].role, Role::Assistant | Role::Tool) {
223        turns.insert(0, Message::user("[context resumed]"));
224    }
225}
226
227/// Layer-4 read-time projection: replace the body of a `Collapsed` tool result with a short
228/// preview, leaving a marker. Non-destructive — the full output stays in `partitions.history`;
229/// only the rendered copy shrinks. Un-collapse is boundary-only (P0-C): handles re-evaluate
230/// from Resident at the next compaction/renewal, never mid-generation (cache-safe monotonic).
231fn collapse_preview(output: &str) -> String {
232    const PREVIEW_BYTES: usize = 160;
233    let mut end = PREVIEW_BYTES.min(output.len());
234    while end > 0 && !output.is_char_boundary(end) {
235        end -= 1;
236    }
237    let dropped = output.len().saturating_sub(end);
238    format!(
239        "{}…\n[collapsed: {dropped} chars projected out of view; full result retained in history]",
240        &output[..end]
241    )
242}
243
244/// Stub substituted for a collapsed assistant preamble. Carries no goal text (that would re-seed the
245/// very repetition this removes) and points the model at the authoritative State turn instead.
246const NARRATION_STUB: &str = "[earlier narration collapsed; tool call(s) preserved below — current progress is in the TASK STATE block]";
247
248/// Minimum narration length (chars, CJK-aware) worth collapsing. Short preambles aren't worth a
249/// stub substitution (and the one-time cache churn it costs as the turn ages out of the window).
250const NARRATION_COLLAPSE_MIN_CHARS: usize = 40;
251
252/// Method 1: read-time collapse of an OLD assistant turn's narration. Targets exactly the
253/// "preamble before action" turns — `Role::Assistant`, a `Content::Text` body, AND a non-empty
254/// `tool_calls` (the model narrated intent, then acted). Returns a projected copy whose text is
255/// replaced by [`NARRATION_STUB`] while `tool_calls` (and thus tool_use/tool_result pairing) are
256/// left intact; the original full text stays in `partitions.history`, so the projection reverses if
257/// the flag is turned off. `None` when the message isn't a collapsible narration turn or the flag is
258/// off. Caller restricts this to messages already past the protected recent window.
259fn project_assistant_narration(msg: &Message, enabled: bool) -> Option<Message> {
260    if !enabled || msg.role != Role::Assistant || msg.tool_calls.is_empty() {
261        return None;
262    }
263    let Content::Text(text) = &msg.content else {
264        return None;
265    };
266    if text == NARRATION_STUB || text.chars().count() < NARRATION_COLLAPSE_MIN_CHARS {
267        return None;
268    }
269    let mut projected = msg.clone();
270    projected.content = Content::Text(NARRATION_STUB.to_string());
271    projected.token_count = None; // recomputed against the smaller stub
272    Some(projected)
273}
274
275/// If any of `msg`'s tool-result parts is `Collapsed` per the handle table, return a projected
276/// copy with those parts previewed; `None` if nothing is collapsed (render the message as-is).
277fn project_message(msg: &Message, handles: &HandleTable) -> Option<Message> {
278    let Content::Parts(parts) = &msg.content else {
279        return None;
280    };
281    let mut changed = false;
282    let new_parts: Vec<ContentPart> = parts
283        .iter()
284        .map(|part| match part {
285            ContentPart::ToolResult { call_id, output, is_error }
286                if matches!(
287                    handles.residency_for_source(call_id),
288                    Some(Residency::Collapsed)
289                ) =>
290            {
291                changed = true;
292                ContentPart::ToolResult {
293                    call_id: call_id.clone(),
294                    output: collapse_preview(output),
295                    is_error: *is_error,
296                }
297            }
298            other => other.clone(),
299        })
300        .collect();
301    if changed {
302        let mut projected = msg.clone();
303        projected.content = Content::Parts(new_parts);
304        projected.token_count = None; // recomputed against the smaller projected body
305        Some(projected)
306    } else {
307        None
308    }
309}
310
311/// Render the context into a `RenderedContext` suitable for a provider API call.
312///
313/// Equivalent to [`render_projected`] with an empty handle table (no Layer-4 projection) and no
314/// frozen-prefix boundary (`frozen_history_len = 0` → `frozen_prefix_len` is always `None`).
315/// Test convenience — the production path is `ContextManager::render` → [`render_projected`].
316#[cfg(test)]
317pub(crate) fn render(
318    partitions: &ContextPartitions,
319    budget: u32,
320    engine: &ContextTokenEngine,
321    preserve_recent_msgs: usize,
322) -> RenderedContext {
323    // The convenience wrapper renders history verbatim (no narration collapse) — callers that want
324    // Method-1 collapse drive `render_projected` with the flag (the kernel passes it from config).
325    render_projected(partitions, budget, engine, preserve_recent_msgs, &HandleTable::new(), 0, false)
326}
327
328/// Render with Layer-4 read-time projection driven by `handles`: tool results whose handle is
329/// `Collapsed` render as previews (originals untouched), freeing budget for more recent turns.
330///
331/// Token budget:
332///   system_stable + system_knowledge tokens are subtracted first.
333///   Remaining budget is allocated to history turns newest-first.
334///   The first `preserve_recent_msgs` history messages are always included.
335///   Text messages are truncated at the budget boundary; Parts messages are included whole.
336pub fn render_projected(
337    partitions: &ContextPartitions,
338    budget: u32,
339    engine: &ContextTokenEngine,
340    preserve_recent_msgs: usize,
341    handles: &HandleTable,
342    frozen_history_len: usize,
343    collapse_narration: bool,
344) -> RenderedContext {
345    let system_stable = build_system_stable(partitions);
346    let system_knowledge = build_system_knowledge(partitions);
347    let system_text = [system_stable.as_str(), system_knowledge.as_str()]
348        .iter()
349        .filter(|s| !s.is_empty())
350        .cloned()
351        .collect::<Vec<_>>()
352        .join("\n\n");
353
354    let system_tokens = engine.count(&system_text).min(budget);
355    let mut remaining = budget.saturating_sub(system_tokens);
356
357    // Fill history newest-first within remaining budget. Layer-4 projection is applied per
358    // message: a collapsed tool result renders as a preview and is costed at its reduced size.
359    let mut kept_rev: Vec<Message> = Vec::new();
360    for msg in partitions.history.messages.iter().rev() {
361        let is_protected = kept_rev.len() < preserve_recent_msgs;
362        // `projected` is `Some` only when read-time projection shrank the message. Two disjoint
363        // sources: a `Collapsed` tool-result preview (handle-driven, any age) OR — once the turn has
364        // aged past the protected recent window — an assistant-narration stub (Method 1). A message
365        // is either a tool-result Parts message or an assistant Text+tool_calls message, never both.
366        let projected = project_message(msg, handles).or_else(|| {
367            if is_protected { None } else { project_assistant_narration(msg, collapse_narration) }
368        });
369        let effective = projected.as_ref().unwrap_or(msg);
370        let tokens = match &projected {
371            Some(p) => engine.count_message(p),
372            None => msg.token_count.unwrap_or_else(|| engine.count_message(msg)),
373        };
374        if tokens == 0 { continue; }
375
376        if is_protected {
377            kept_rev.push(effective.clone());
378            remaining = remaining.saturating_sub(tokens);
379            continue;
380        }
381
382        if tokens <= remaining {
383            kept_rev.push(effective.clone());
384            remaining = remaining.saturating_sub(tokens);
385        } else if remaining > 0 {
386            match &effective.content {
387                // P0-B1: drop a Text boundary message **whole** rather than mid-truncate. A
388                // truncated body's bytes depend on `remaining`, which varies per turn — that churns
389                // turns[0] and invalidates the entire cached prefix. Compaction normally keeps
390                // history under budget, so this overflow path is a rare safety net; keeping every
391                // kept turn a complete message preserves prompt-cache reuse.
392                Content::Text(_) => {}
393                // A Parts message was already included whole (byte-stable) — unchanged.
394                Content::Parts(_) => kept_rev.push(effective.clone()),
395            }
396            break;
397        } else {
398            break;
399        }
400    }
401
402    kept_rev.reverse();
403    let mut turns = kept_rev;
404    normalize_turn_prefix(&mut turns);
405
406    // The State turn (task_state + signals) is volatile — keep it OUT of the
407    // cacheable history. Providers render it after the history (Anthropic) or
408    // prepended (OpenAI). See RenderedContext docs.
409    let state_turn = build_state_turn(partitions);
410
411    // P1-E: locate the frozen-prefix boundary in rendered turns. `frozen_history_len` is the
412    // history length as of the last compaction (0 before any) — messages beyond it are the hot
413    // tail that grows each turn. We count the hot tail from the END, which is robust to the leading
414    // anchor and to budget-dropping of OLD turns (the recent tail is never dropped). Emit `Some`
415    // only for a distinct, non-empty frozen region; otherwise providers use the rolling-pair
416    // fallback (deep == tail would waste a breakpoint).
417    let hot = partitions
418        .history
419        .messages
420        .len()
421        .saturating_sub(frozen_history_len);
422    let frozen_prefix_len = if frozen_history_len > 0 && hot > 0 && hot < turns.len() {
423        Some(turns.len() - hot)
424    } else {
425        None
426    };
427
428    RenderedContext { system_text, system_stable, system_knowledge, turns, state_turn, frozen_prefix_len }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::context::config::ContextConfig;
435    use crate::context::partitions::ContextPartitions;
436    use crate::context::task_state::{PlanStep, TaskState};
437    use crate::context::token_engine::ContextTokenEngine;
438    use crate::types::message::{Message, Role};
439
440    fn engine() -> ContextTokenEngine { ContextTokenEngine::char_approx() }
441    fn ctx() -> ContextPartitions { ContextPartitions::new(&ContextConfig::default()) }
442
443    #[test]
444    fn system_stable_contains_system_partition() {
445        let mut c = ctx();
446        c.system.push(Message::system("You are helpful."), 10);
447        let rc = render(&c, 10_000, &engine(), 4);
448        assert!(rc.system_stable.contains("You are helpful."));
449        assert!(rc.system_text.contains("You are helpful."));
450    }
451
452    #[test]
453    fn system_knowledge_contains_knowledge_partition() {
454        let mut c = ctx();
455        c.knowledge.push(Message::system("skill: debug"), 10);
456        let rc = render(&c, 10_000, &engine(), 4);
457        assert!(rc.system_knowledge.contains("skill: debug"));
458        assert!(rc.system_text.contains("skill: debug"));
459    }
460
461    #[test]
462    fn task_state_appears_in_state_turn() {
463        let mut c = ctx();
464        c.task_state = TaskState { goal: "find the bug".to_string(), ..Default::default() };
465        let rc = render(&c, 10_000, &engine(), 4);
466        assert!(!rc.system_text.contains("[TASK STATE]"), "task_state must not be in system_text");
467        let state = rc.state_turn.as_ref().expect("should have a state turn");
468        assert_eq!(state.role, Role::User);
469        assert!(state.content.as_text().unwrap().contains("[TASK STATE] goal: find the bug"));
470        // State is NOT in the cacheable history turns.
471        assert!(!rc.turns.iter().any(|m| m.content.as_text().map(|t| t.contains("[TASK STATE]")).unwrap_or(false)));
472    }
473
474    #[test]
475    fn signals_appear_in_state_turn() {
476        let mut c = ctx();
477        c.task_state = TaskState { goal: "g".to_string(), ..Default::default() };
478        c.signals.push("[ROLLBACK] tool failed".to_string());
479        let rc = render(&c, 10_000, &engine(), 4);
480        let state = rc.state_turn.as_ref().unwrap();
481        assert!(state.content.as_text().unwrap().contains("[ROLLBACK] tool failed"));
482    }
483
484    #[test]
485    fn empty_task_state_no_state_turn() {
486        let c = ctx();
487        let rc = render(&c, 10_000, &engine(), 4);
488        // No state turn when task_state is empty and no signals
489        assert!(rc.state_turn.is_none());
490        assert!(rc.turns.is_empty());
491    }
492
493    #[test]
494    fn history_excludes_state_turn() {
495        let mut c = ctx();
496        c.task_state = TaskState { goal: "g".to_string(), ..Default::default() };
497        c.history.push(Message::user("step 1"), 5);
498        c.history.push(Message::assistant("done"), 5);
499        let rc = render(&c, 10_000, &engine(), 4);
500        // turns is history only; state lives in state_turn.
501        assert!(rc.state_turn.as_ref().unwrap().content.as_text().unwrap().contains("[TASK STATE]"));
502        assert_eq!(rc.turns[0].role, Role::User);
503        assert_eq!(rc.turns[0].content.as_text(), Some("step 1"));
504        assert_eq!(rc.turns[1].role, Role::Assistant);
505    }
506
507    #[test]
508    fn all_assistant_tool_history_gets_anchor_user_turn() {
509        let mut c = ctx();
510        c.history.push(Message::assistant("reply"), 5);
511        let rc = render(&c, 10_000, &engine(), 4);
512        assert_eq!(rc.turns[0].role, Role::User);
513    }
514
515    #[test]
516    fn zero_token_messages_skipped() {
517        let mut c = ctx();
518        c.history.push(Message::user("zero"), 0);
519        c.history.push(Message::user("real"), 5);
520        let rc = render(&c, 10_000, &engine(), 4);
521        // Only "real" in history turns (state turn absent — no task_state)
522        assert!(rc.turns.iter().any(|m| m.content.as_text() == Some("real")));
523        assert!(!rc.turns.iter().any(|m| m.content.as_text() == Some("zero")));
524    }
525
526    #[test]
527    fn collapsed_tool_result_renders_as_preview_without_mutating_history() {
528        use crate::mm::handle::{Handle, HandleKind, HandleTable, Residency};
529
530        let mut c = ctx();
531        let long = "DATA ".repeat(200); // 1000 bytes
532        c.history.push(
533            Message::tool(vec![ContentPart::ToolResult {
534                call_id: "c1".into(),
535                output: long.clone(),
536                is_error: false,
537            }]),
538            250,
539        );
540
541        let mut handles = HandleTable::new();
542        let mut h = Handle::resident_for(1, HandleKind::ToolResult, 250, "c1");
543        h.residency = Residency::Collapsed;
544        handles.insert(h);
545
546        let rc = render_projected(&c, 10_000, &engine(), 4, &handles, 0, false);
547        let rendered: String = rc
548            .turns
549            .iter()
550            .flat_map(|m| match &m.content {
551                Content::Parts(parts) => parts.clone(),
552                _ => Vec::new(),
553            })
554            .find_map(|p| match p {
555                ContentPart::ToolResult { output, .. } => Some(output),
556                _ => None,
557            })
558            .expect("tool result rendered");
559        // Rendered copy is a preview; original full output is retained in history.
560        assert!(rendered.contains("[collapsed:"));
561        assert!(rendered.len() < long.len());
562        let stored = match &c.history.messages[0].content {
563            Content::Parts(parts) => match &parts[0] {
564                ContentPart::ToolResult { output, .. } => output.clone(),
565                _ => unreachable!(),
566            },
567            _ => unreachable!(),
568        };
569        assert_eq!(stored, long, "projection must not mutate stored history");
570    }
571
572    #[test]
573    fn resident_tool_result_renders_in_full() {
574        use crate::mm::handle::{Handle, HandleKind, HandleTable};
575
576        let mut c = ctx();
577        let body = "RESIDENT BODY ".repeat(20);
578        c.history.push(
579            Message::tool(vec![ContentPart::ToolResult {
580                call_id: "c2".into(),
581                output: body.clone(),
582                is_error: false,
583            }]),
584            60,
585        );
586        let mut handles = HandleTable::new();
587        handles.insert(Handle::resident_for(1, HandleKind::ToolResult, 60, "c2"));
588
589        let rc = render_projected(&c, 10_000, &engine(), 4, &handles, 0, false);
590        let rendered: String = rc
591            .turns
592            .iter()
593            .flat_map(|m| match &m.content {
594                Content::Parts(parts) => parts.clone(),
595                _ => Vec::new(),
596            })
597            .find_map(|p| match p {
598                ContentPart::ToolResult { output, .. } => Some(output),
599                _ => None,
600            })
601            .expect("tool result rendered");
602        assert_eq!(rendered, body);
603        assert!(!rendered.contains("[collapsed:"));
604    }
605
606    // ── P1-F: state-turn recency footer ───────────────────────────────────
607
608    #[test]
609    fn state_turn_footer_leads_with_next_step_not_bare_goal() {
610        let mut c = ctx();
611        c.task_state = TaskState {
612            goal: "ship the cache work".to_string(),
613            plan: vec![PlanStep { label: "do E".to_string(), done: false }],
614            current_step: Some(0),
615            ..Default::default()
616        };
617        c.task_state.record_directive("don't break ABI");
618        let rc = render(&c, 100_000, &engine(), 4);
619        let text = rc.state_turn.unwrap().content.as_text().unwrap().to_string();
620
621        // The full TASK STATE block still LEADS (primacy) — goal-adherence preserved ...
622        assert!(text.starts_with("[TASK STATE] goal: ship the cache work"));
623        // ... but the peak-attention footer leads with the forward action, not a goal restatement.
624        let before_proceed = text.rsplit_once("\n\nProceed.").expect("ends with Proceed").0;
625        let last_block = before_proceed.rsplit("\n\n").next().unwrap();
626        assert!(last_block.starts_with("→ next: step 1 — do E"), "got: {last_block}");
627        assert!(last_block.contains("must: don't break ABI"));
628        // The bare goal must NOT be re-injected at the peak-attention tail (the repetition fuel).
629        assert!(!last_block.contains("focus: ship the cache work"), "got: {last_block}");
630    }
631
632    #[test]
633    fn footer_falls_back_to_focus_goal_when_nothing_done_yet() {
634        // Turn 1: no actions, no plan — the footer surfaces the goal so the model knows the objective.
635        let mut c = ctx();
636        c.task_state = TaskState { goal: "build the thing".to_string(), ..Default::default() };
637        let rc = render(&c, 100_000, &engine(), 4);
638        let text = rc.state_turn.unwrap().content.as_text().unwrap().to_string();
639        let footer = text.rsplit_once("\n\nProceed.").unwrap().0.rsplit("\n\n").next().unwrap();
640        assert_eq!(footer, "→ focus: build the thing");
641    }
642
643    #[test]
644    fn footer_shows_recent_actions_and_forward_nudge_without_a_plan() {
645        // No curated plan, but real tool activity (2b) → the footer shows motion + a forward nudge,
646        // and the goal is NOT restated at the tail.
647        let mut c = ctx();
648        c.task_state = TaskState { goal: "rebuild §4.4 as SVG".to_string(), ..Default::default() };
649        c.task_state.note_actions("module_list");
650        c.task_state.note_actions("module_read");
651        let rc = render(&c, 100_000, &engine(), 4);
652        let footer = rc.state_turn.unwrap().content.as_text().unwrap()
653            .rsplit_once("\n\nProceed.").unwrap().0.rsplit("\n\n").next().unwrap().to_string();
654        assert!(footer.contains("did: module_list → module_read"), "got: {footer}");
655        assert!(footer.contains("next: advance the goal"), "got: {footer}");
656        assert!(!footer.contains("focus: rebuild §4.4 as SVG"), "goal must not lead the footer");
657    }
658
659    #[test]
660    fn footer_raises_stop_on_repeated_action() {
661        // The same action on the last ≥2 turns ⇒ explicit STOP backstop (breaks the read-loop in-band).
662        let mut c = ctx();
663        c.task_state = TaskState { goal: "g".to_string(), ..Default::default() };
664        c.task_state.note_actions("document_read");
665        c.task_state.note_actions("document_read");
666        c.task_state.note_actions("document_read");
667        let rc = render(&c, 100_000, &engine(), 4);
668        let footer = rc.state_turn.unwrap().content.as_text().unwrap()
669            .rsplit_once("\n\nProceed.").unwrap().0.rsplit("\n\n").next().unwrap().to_string();
670        assert!(footer.contains("STOP: `document_read` repeated 3×"), "got: {footer}");
671    }
672
673    #[test]
674    fn no_salience_footer_without_a_goal() {
675        let mut c = ctx();
676        c.signals.push("[ROLLBACK] tool failed".to_string());
677        let rc = render(&c, 100_000, &engine(), 4);
678        let text = rc.state_turn.unwrap().content.as_text().unwrap().to_string();
679        assert!(!text.contains("→ focus:"), "no goal ⇒ no footer");
680        // signals remain the last content before the anchor.
681        assert!(text.contains("[ROLLBACK] tool failed"));
682    }
683
684    // ── P0-A: prefix fingerprint (cache-drift instrument) ──────────────────
685
686    #[test]
687    fn prefix_fingerprint_is_stable_when_appending_history() {
688        let mut c = ctx();
689        c.system.push(Message::system("rules"), 5);
690        c.knowledge.push(Message::system("skill: debug"), 5);
691        c.history.push(Message::user("turn A"), 5);
692        c.history.push(Message::assistant("turn B"), 5);
693        let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
694
695        // Append a new turn — the existing prefix must stay byte-identical.
696        c.history.push(Message::user("turn C"), 5);
697        let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
698
699        assert!(fp2.extends(&fp1), "appending must only grow the tail, never drift the prefix");
700        assert_eq!(fp2.common_turn_prefix(&fp1), 2, "both prior turns stay cache-reusable");
701        assert_eq!(fp2.turn_hashes.len(), 3);
702    }
703
704    #[test]
705    fn prefix_fingerprint_ignores_state_turn() {
706        // Same history, different task_state/signals → the cacheable prefix is
707        // identical (state lives in the uncached tail, out of `turns`).
708        let mut c = ctx();
709        c.history.push(Message::user("turn A"), 5);
710        c.task_state = TaskState { goal: "first goal".to_string(), ..Default::default() };
711        let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
712
713        c.task_state = TaskState { goal: "totally different goal".to_string(), ..Default::default() };
714        c.signals.push("[ROLLBACK] whatever".to_string());
715        let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
716
717        assert_eq!(fp1, fp2, "volatile state must not perturb the cacheable prefix");
718    }
719
720    #[test]
721    fn prefix_fingerprint_detects_system_drift() {
722        let mut c = ctx();
723        c.system.push(Message::system("rules v1"), 5);
724        c.history.push(Message::user("turn A"), 5);
725        let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
726
727        c.system.messages.clear();
728        c.system.push(Message::system("rules v2"), 5);
729        let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
730
731        assert_ne!(fp1.system_stable_hash, fp2.system_stable_hash);
732        assert!(!fp2.extends(&fp1), "a system-block edit invalidates the whole prefix");
733    }
734
735    #[test]
736    fn prefix_fingerprint_detects_in_place_collapse_churn() {
737        use crate::mm::handle::{Handle, HandleKind, HandleTable, Residency};
738
739        let mut c = ctx();
740        c.history.push(Message::user("start"), 5);
741        let long = "DATA ".repeat(200);
742        c.history.push(
743            Message::tool(vec![ContentPart::ToolResult {
744                call_id: "c1".into(),
745                output: long,
746                is_error: false,
747            }]),
748            250,
749        );
750        c.history.push(Message::user("recent"), 5);
751
752        let resident = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
753
754        // Collapsing the old tool result rewrites that turn in place → the prefix
755        // hash at that position changes (the cache-cost of folding, made visible).
756        let mut handles = HandleTable::new();
757        let mut h = Handle::resident_for(1, HandleKind::ToolResult, 250, "c1");
758        h.residency = Residency::Collapsed;
759        handles.insert(h);
760        let collapsed = render_projected(&c, 100_000, &engine(), 4, &handles, 0, false).prefix_fingerprint();
761
762        // turn 0 ("start") is byte-stable; the collapsed tool result at turn 1 drifts.
763        assert_eq!(collapsed.common_turn_prefix(&resident), 1, "drift begins at the collapsed turn");
764        assert!(!collapsed.extends(&resident));
765    }
766
767    // ── Method 1: assistant-narration collapse ─────────────────────────────
768
769    fn assistant_with_call(text: &str) -> Message {
770        let mut m = Message::assistant(text);
771        m.tool_calls = vec![crate::types::message::ToolCall {
772            id: "c1".into(),
773            name: "module_read".into(),
774            arguments: serde_json::json!({}),
775        }];
776        m
777    }
778
779    #[test]
780    fn old_assistant_narration_collapses_keeping_tool_calls() {
781        let mut c = ctx();
782        // Oldest = a long preamble + a tool call; then enough recent turns to push it past the window.
783        c.history.push(assistant_with_call(&"好的,我来将 §4.4 的 Mermaid 部署架构图重新构建为 SVG 版本。先找到当前 Mermaid 模块的位置。".repeat(1)), 60);
784        for i in 0..5 { c.history.push(Message::user(format!("recent {i}")), 5); }
785
786        // collapse ON (preserve window = 4, so the oldest narration turn is past it)
787        let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
788        let narration = rc
789            .turns
790            .iter()
791            .find(|m| m.content.as_text() == Some(NARRATION_STUB))
792            .expect("old narration replaced by stub");
793        assert_eq!(narration.tool_calls.len(), 1, "tool call (pairing) preserved");
794        assert_eq!(narration.tool_calls[0].name, "module_read");
795        // No verbatim preamble survives in the rendered prefix.
796        assert!(!rc.turns.iter().any(|m| m.content.as_text().map(|t| t.contains("先找到当前 Mermaid")).unwrap_or(false)));
797        // Original history is untouched (non-destructive projection).
798        assert!(c.history.messages[0].content.as_text().unwrap().contains("先找到当前 Mermaid"));
799
800        // collapse OFF → verbatim narration survives.
801        let rc_off = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, false);
802        assert!(rc_off.turns.iter().any(|m| m.content.as_text().map(|t| t.contains("先找到当前 Mermaid")).unwrap_or(false)));
803    }
804
805    #[test]
806    fn recent_assistant_narration_within_window_is_not_collapsed() {
807        let mut c = ctx();
808        // Only 2 turns, preserve window = 4 → the narration turn is protected → never collapsed.
809        c.history.push(assistant_with_call(&"好的,我来将 §4.4 重新构建为 SVG。先定位模块位置确认范围读取内容。".to_string()), 60);
810        c.history.push(Message::user("ok"), 5);
811        let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
812        assert!(rc.turns.iter().any(|m| m.content.as_text().map(|t| t.contains("先定位模块位置")).unwrap_or(false)), "recent narration kept verbatim");
813    }
814
815    #[test]
816    fn assistant_without_tool_calls_is_never_collapsed() {
817        let mut c = ctx();
818        // A pure final answer (no tool calls) is substantive — must survive even when old.
819        c.history.push(Message::assistant("这是给用户的最终结论,包含实质内容,不应被折叠掉以免丢信息。"), 40);
820        for i in 0..5 { c.history.push(Message::user(format!("r{i}")), 5); }
821        let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
822        assert!(rc.turns.iter().any(|m| m.content.as_text().map(|t| t.contains("最终结论")).unwrap_or(false)), "answer-only turns are not narration");
823    }
824
825    #[test]
826    fn collapsing_narration_drifts_only_that_turn_in_the_cache_prefix() {
827        // The cost made visible: collapsing rewrites that one turn in place → the prefix hash drifts
828        // at its position (one-time, as it ages past the window), but earlier turns stay reusable.
829        let mut c = ctx();
830        c.history.push(Message::user("start"), 5);
831        c.history.push(assistant_with_call(&"好的,我来将 §4.4 重新构建为 SVG 版本。先找到 Mermaid 模块的确切位置再读取其内容。".to_string()), 60);
832        for i in 0..4 { c.history.push(Message::user(format!("recent {i}")), 5); }
833
834        let verbatim = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, false).prefix_fingerprint();
835        let collapsed = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true).prefix_fingerprint();
836        // turn 0 ("start") is byte-stable; drift begins at the collapsed narration turn (index 1).
837        assert_eq!(collapsed.common_turn_prefix(&verbatim), 1, "only the collapsed turn drifts");
838        assert!(!collapsed.extends(&verbatim));
839    }
840
841    #[test]
842    fn protected_recent_messages_kept_whole_over_budget() {
843        let mut c = ctx();
844        c.history.push(Message::user("first message"), 5);
845        c.history.push(Message::user("a".repeat(1000)), 250);
846        // preserve_recent_msgs=4 protects both — kept whole regardless of the 10-token budget.
847        let rc = render(&c, 10, &engine(), 4);
848        assert!(rc.turns.iter().any(|m| {
849            m.content.as_text().map(|t| t.contains("first message")).unwrap_or(false)
850        }));
851    }
852
853    #[test]
854    fn oversized_text_boundary_is_dropped_whole_not_truncated() {
855        // P0-B1: an unprotected, over-budget Text boundary message is dropped whole — never
856        // mid-truncated — so no budget-dependent fragment lands in the cached prefix.
857        let mut c = ctx();
858        c.history.push(Message::user("a".repeat(1000)), 250); // oldest, oversized
859        c.history.push(Message::user("recent"), 2); // newest, fits
860        let rc = render(&c, 5, &engine(), 0); // nothing protected
861        assert_eq!(rc.turns.len(), 1, "only the fitting newest turn survives");
862        assert_eq!(rc.turns[0].content.as_text(), Some("recent"));
863        assert!(
864            !rc.turns.iter().any(|m| m
865                .content
866                .as_text()
867                .map(|t| t.starts_with("aaaa"))
868                .unwrap_or(false)),
869            "no truncated body in the prefix"
870        );
871    }
872}