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#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct InternalRenderedContext {
36 pub system_text: String,
38 pub system_stable: String,
40 pub system_knowledge: String,
42 pub turns: Vec<CoreMessage>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub state_turn: Option<CoreMessage>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub frozen_prefix_len: Option<usize>,
56 #[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#[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 pub turn_hashes: Vec<u64>,
92}
93
94#[cfg(test)]
95impl PrefixFingerprint {
96 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 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#[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 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
162fn 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 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 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 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 let body = if clauses.is_empty() {
224 format!("→ focus: {}", ts.goal)
225 } else {
226 format!("→ {}", clauses.join(" · "))
227 };
228 Some(body)
229}
230
231fn 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
254fn 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
262pub(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
292const NARRATION_STUB: &str = "[earlier narration collapsed; tool call(s) preserved below — current progress is in the TASK STATE block]";
295
296const NARRATION_COLLAPSE_MIN_CHARS: usize = 40;
299
300fn 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
322fn 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#[cfg(test)]
368pub(crate) fn render(
369 partitions: &ContextPartitions,
370 budget: u32,
371 engine: &ContextTokenEngine,
372 preserve_recent_units: usize,
373) -> InternalRenderedContext {
374 render_projected(
377 partitions,
378 budget,
379 engine,
380 preserve_recent_units,
381 &HandleTable::new(),
382 0,
383 false,
384 )
385}
386
387pub 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#[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 pub history: Vec<HistorySelectionTrace>,
428 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 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 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 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 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; 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 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 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 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 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); 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 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 #[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 assert!(text.starts_with("[TASK STATE] goal: ship the cache work"));
964 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 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 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 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 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 assert!(text.contains("[ROLLBACK] tool failed"));
1090 }
1091
1092 #[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 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 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 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 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 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 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 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 assert!(!rc.turns.iter().any(|m| {
1245 m.content
1246 .as_text()
1247 .map(|t| t.contains("先找到当前 Mermaid"))
1248 .unwrap_or(false)
1249 }));
1250 assert!(
1252 c.history.messages[0]
1253 .content
1254 .as_text()
1255 .unwrap()
1256 .contains("先找到当前 Mermaid")
1257 );
1258
1259 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 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 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 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 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 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 let mut c = ctx();
1406 c.history.push(CoreMessage::user("a".repeat(1000)), 250); c.history.push(CoreMessage::user("recent"), 2); let rc = render(&c, 5, &engine(), 0); 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}