1#[cfg(test)]
2use super::fault::stable_hash;
3use super::partitions::ContextPartitions;
4use super::task_state::TaskState;
5use super::token_engine::ContextTokenEngine;
6use super::units::{strict_tool_pairing_is_valid, unit_boundaries};
7use crate::mm::handle::{HandleTable, Residency};
8use crate::types::message::{Content, ContentPart, Message, Role};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RenderedContext {
29 pub system_text: String,
31 pub system_stable: String,
33 pub system_knowledge: String,
35 pub turns: Vec<Message>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub state_turn: Option<Message>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub frozen_prefix_len: Option<usize>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub budget_overflow: Option<ContextBudgetOverflow>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum ContextBudgetOverflowKind {
59 FixedContext,
60 ProtectedTail,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ContextBudgetOverflow {
65 pub kind: ContextBudgetOverflowKind,
66 pub required_tokens: u32,
67 pub max_tokens: u32,
68}
69
70#[cfg(test)]
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub(crate) struct PrefixFingerprint {
80 pub system_stable_hash: u64,
81 pub system_knowledge_hash: u64,
82 pub turn_hashes: Vec<u64>,
85}
86
87#[cfg(test)]
88impl PrefixFingerprint {
89 pub(crate) fn extends(&self, prev: &PrefixFingerprint) -> bool {
94 self.system_stable_hash == prev.system_stable_hash
95 && self.system_knowledge_hash == prev.system_knowledge_hash
96 && prev.turn_hashes.len() <= self.turn_hashes.len()
97 && self.turn_hashes[..prev.turn_hashes.len()] == prev.turn_hashes[..]
98 }
99
100 pub(crate) fn common_turn_prefix(&self, prev: &PrefixFingerprint) -> usize {
104 self.turn_hashes
105 .iter()
106 .zip(prev.turn_hashes.iter())
107 .take_while(|(a, b)| a == b)
108 .count()
109 }
110}
111
112#[cfg(test)]
117fn hash_turn(msg: &Message) -> u64 {
118 let material =
119 serde_json::to_vec(&(&msg.role, &msg.content, &msg.tool_calls)).unwrap_or_default();
120 stable_hash(&material)
121}
122
123#[cfg(test)]
124impl RenderedContext {
125 pub(crate) fn prefix_fingerprint(&self) -> PrefixFingerprint {
128 PrefixFingerprint {
129 system_stable_hash: stable_hash(self.system_stable.as_bytes()),
130 system_knowledge_hash: stable_hash(self.system_knowledge.as_bytes()),
131 turn_hashes: self.turns.iter().map(hash_turn).collect(),
132 }
133 }
134}
135
136fn build_system_stable(partitions: &ContextPartitions) -> String {
137 partitions
138 .system
139 .messages
140 .iter()
141 .filter_map(|m| m.content.as_text())
142 .collect::<Vec<_>>()
143 .join("\n\n")
144}
145
146fn build_system_knowledge(partitions: &ContextPartitions) -> String {
147 partitions
148 .knowledge
149 .messages()
150 .filter_map(|m| m.content.as_text())
151 .collect::<Vec<_>>()
152 .join("\n\n")
153}
154
155fn salience_footer(ts: &TaskState) -> Option<String> {
169 if ts.goal.is_empty() {
170 return None;
171 }
172 let mut clauses: Vec<String> = Vec::new();
173
174 let recent = ts.recent_actions.as_slice();
178 let action_name = |entry: &str| entry.split('(').next().unwrap_or(entry).to_string();
179 if let Some(last) = recent.last() {
180 let start = recent.len().saturating_sub(3);
181 let names = recent[start..]
182 .iter()
183 .map(|e| action_name(e))
184 .collect::<Vec<_>>()
185 .join(" → ");
186 clauses.push(format!("did: {names}"));
187
188 let trailing_repeat = recent.iter().rev().take_while(|a| *a == last).count();
191 if trailing_repeat >= 2 {
192 clauses.push(format!(
193 "STOP: `{}` repeated {trailing_repeat}× unchanged — do something different or report",
194 action_name(last)
195 ));
196 }
197 }
198
199 let active_step = ts
201 .current_step
202 .and_then(|i| ts.plan.get(i).map(|s| (i, s)))
203 .filter(|(_, s)| !s.done);
204 if let Some((i, step)) = active_step {
205 clauses.push(format!("next: step {} — {}", i + 1, step.label));
206 } else if !recent.is_empty() {
207 clauses.push("next: advance the goal".to_string());
208 }
209
210 if let Some(d) = ts.directives.last() {
211 clauses.push(format!("must: {d}"));
212 }
213
214 let body = if clauses.is_empty() {
217 format!("→ focus: {}", ts.goal)
218 } else {
219 format!("→ {}", clauses.join(" · "))
220 };
221 Some(body)
222}
223
224fn build_state_turn(partitions: &ContextPartitions) -> Option<Message> {
228 let task = partitions.task_state.format_compact();
229 if task.is_empty() && partitions.signals.is_empty() {
230 return None;
231 }
232 let mut parts: Vec<String> = Vec::new();
233 if !task.is_empty() {
234 parts.push(task);
235 }
236 let signals_text = partitions.signals.join("\n");
237 if !signals_text.is_empty() {
238 parts.push(signals_text);
239 }
240 if let Some(footer) = salience_footer(&partitions.task_state) {
241 parts.push(footer);
242 }
243 let body = parts.join("\n\n");
244 Some(Message::user(format!("{body}\n\nProceed.")))
245}
246
247fn normalize_turn_prefix(turns: &mut Vec<Message>) {
250 if !turns.is_empty() && matches!(turns[0].role, Role::Assistant | Role::Tool) {
251 turns.insert(0, Message::user("[context resumed]"));
252 }
253}
254
255fn collapse_preview(output: &str, call_id: &str) -> String {
263 const PREVIEW_BYTES: usize = 160;
264 let mut end = PREVIEW_BYTES.min(output.len());
265 while end > 0 && !output.is_char_boundary(end) {
266 end -= 1;
267 }
268 let dropped = output.len().saturating_sub(end);
269 format!(
270 "{}…\n[Output truncated: {dropped} bytes omitted. Call the read_result tool with call_id \"{call_id}\" to re-read the full result.]",
271 &output[..end]
272 )
273}
274
275const NARRATION_STUB: &str = "[earlier narration collapsed; tool call(s) preserved below — current progress is in the TASK STATE block]";
278
279const NARRATION_COLLAPSE_MIN_CHARS: usize = 40;
282
283fn project_assistant_narration(msg: &Message, enabled: bool) -> Option<Message> {
291 if !enabled || msg.role != Role::Assistant || msg.tool_calls.is_empty() {
292 return None;
293 }
294 let Content::Text(text) = &msg.content else {
295 return None;
296 };
297 if text == NARRATION_STUB || text.chars().count() < NARRATION_COLLAPSE_MIN_CHARS {
298 return None;
299 }
300 let mut projected = msg.clone();
301 projected.content = Content::Text(NARRATION_STUB.to_string());
302 projected.token_count = None; Some(projected)
304}
305
306fn project_message(msg: &Message, handles: &HandleTable) -> Option<Message> {
309 let Content::Parts(parts) = &msg.content else {
310 return None;
311 };
312 let mut changed = false;
313 let new_parts: Vec<ContentPart> = parts
314 .iter()
315 .map(|part| match part {
316 ContentPart::ToolResult {
317 call_id,
318 output,
319 is_error,
320 } if matches!(
321 handles.residency_for_source(call_id),
322 Some(Residency::Collapsed)
323 ) =>
324 {
325 changed = true;
326 ContentPart::ToolResult {
327 call_id: call_id.clone(),
328 output: collapse_preview(output, call_id.as_str()),
329 is_error: *is_error,
330 }
331 }
332 other => other.clone(),
333 })
334 .collect();
335 if changed {
336 let mut projected = msg.clone();
337 projected.content = Content::Parts(new_parts);
338 projected.token_count = None; Some(projected)
340 } else {
341 None
342 }
343}
344
345#[cfg(test)]
351pub(crate) fn render(
352 partitions: &ContextPartitions,
353 budget: u32,
354 engine: &ContextTokenEngine,
355 preserve_recent_units: usize,
356) -> RenderedContext {
357 render_projected(
360 partitions,
361 budget,
362 engine,
363 preserve_recent_units,
364 &HandleTable::new(),
365 0,
366 false,
367 )
368}
369
370pub fn render_projected(
379 partitions: &ContextPartitions,
380 budget: u32,
381 engine: &ContextTokenEngine,
382 preserve_recent_units: usize,
383 handles: &HandleTable,
384 frozen_history_len: usize,
385 collapse_narration: bool,
386) -> RenderedContext {
387 let system_stable = build_system_stable(partitions);
388 let system_knowledge = build_system_knowledge(partitions);
389 let system_text = [system_stable.as_str(), system_knowledge.as_str()]
390 .iter()
391 .filter(|s| !s.is_empty())
392 .cloned()
393 .collect::<Vec<_>>()
394 .join("\n\n");
395
396 let system_tokens = engine.count(&system_text);
399 let state_turn = build_state_turn(partitions);
400 let state_tokens = state_turn
401 .as_ref()
402 .map_or(0, |message| engine.count_message(message));
403 let fixed_tokens = system_tokens.saturating_add(state_tokens);
404 let mut remaining = budget.saturating_sub(fixed_tokens);
405 let mut used_tokens = fixed_tokens;
406 let mut budget_overflow = (fixed_tokens > budget).then_some(ContextBudgetOverflow {
407 kind: ContextBudgetOverflowKind::FixedContext,
408 required_tokens: fixed_tokens,
409 max_tokens: budget,
410 });
411
412 let units = unit_boundaries(&partitions.history.messages);
413 let protected_from = units.len().saturating_sub(preserve_recent_units);
414 let mut kept_units_rev: Vec<Vec<Message>> = Vec::new();
415
416 for (unit_index, unit) in units.iter().enumerate().rev() {
417 let is_protected = unit_index >= protected_from;
418 let effective = partitions.history.messages[unit.clone()]
419 .iter()
420 .map(|msg| {
421 project_message(msg, handles)
422 .or_else(|| {
423 if is_protected {
424 None
425 } else {
426 project_assistant_narration(msg, collapse_narration)
427 }
428 })
429 .unwrap_or_else(|| msg.clone())
430 })
431 .collect::<Vec<_>>();
432 let tokens = effective
433 .iter()
434 .map(|msg| msg.token_count.unwrap_or_else(|| engine.count_message(msg)))
435 .sum::<u32>();
436 if tokens == 0 {
437 continue;
438 }
439
440 if is_protected || tokens <= remaining {
441 kept_units_rev.push(effective);
442 remaining = remaining.saturating_sub(tokens);
443 used_tokens = used_tokens.saturating_add(tokens);
444 if is_protected && used_tokens > budget && budget_overflow.is_none() {
445 budget_overflow = Some(ContextBudgetOverflow {
446 kind: ContextBudgetOverflowKind::ProtectedTail,
447 required_tokens: used_tokens,
448 max_tokens: budget,
449 });
450 }
451 } else {
452 break;
453 }
454 }
455
456 kept_units_rev.reverse();
457 let mut turns = kept_units_rev.into_iter().flatten().collect::<Vec<_>>();
458 normalize_turn_prefix(&mut turns);
459 debug_assert!(
460 !strict_tool_pairing_is_valid(&partitions.history.messages)
461 || strict_tool_pairing_is_valid(&turns),
462 "renderer split a valid tool transaction"
463 );
464
465 let hot = partitions
472 .history
473 .messages
474 .len()
475 .saturating_sub(frozen_history_len);
476 let frozen_prefix_len = if frozen_history_len > 0 && hot > 0 && hot < turns.len() {
477 Some(turns.len() - hot)
478 } else {
479 None
480 };
481
482 RenderedContext {
483 system_text,
484 system_stable,
485 system_knowledge,
486 turns,
487 state_turn,
488 frozen_prefix_len,
489 budget_overflow,
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use crate::context::config::ContextConfig;
497 use crate::context::partitions::ContextPartitions;
498 use crate::context::task_state::{PlanStep, TaskState};
499 use crate::context::token_engine::ContextTokenEngine;
500 use crate::types::message::{Message, Role};
501
502 fn engine() -> ContextTokenEngine {
503 ContextTokenEngine::char_approx()
504 }
505 fn ctx() -> ContextPartitions {
506 ContextPartitions::new(&ContextConfig::default())
507 }
508
509 #[test]
510 fn system_stable_contains_system_partition() {
511 let mut c = ctx();
512 c.system.push(Message::system("You are helpful."), 10);
513 let rc = render(&c, 10_000, &engine(), 4);
514 assert!(rc.system_stable.contains("You are helpful."));
515 assert!(rc.system_text.contains("You are helpful."));
516 }
517
518 #[test]
519 fn system_knowledge_contains_knowledge_partition() {
520 let mut c = ctx();
521 c.knowledge.push(Message::system("skill: debug"), 10);
522 let rc = render(&c, 10_000, &engine(), 4);
523 assert!(rc.system_knowledge.contains("skill: debug"));
524 assert!(rc.system_text.contains("skill: debug"));
525 }
526
527 #[test]
528 fn task_state_appears_in_state_turn() {
529 let mut c = ctx();
530 c.task_state = TaskState {
531 goal: "find the bug".to_string(),
532 ..Default::default()
533 };
534 let rc = render(&c, 10_000, &engine(), 4);
535 assert!(
536 !rc.system_text.contains("[TASK STATE]"),
537 "task_state must not be in system_text"
538 );
539 let state = rc.state_turn.as_ref().expect("should have a state turn");
540 assert_eq!(state.role, Role::User);
541 assert!(
542 state
543 .content
544 .as_text()
545 .unwrap()
546 .contains("[TASK STATE] goal: find the bug")
547 );
548 assert!(!rc.turns.iter().any(|m| {
550 m.content
551 .as_text()
552 .map(|t| t.contains("[TASK STATE]"))
553 .unwrap_or(false)
554 }));
555 }
556
557 #[test]
558 fn signals_appear_in_state_turn() {
559 let mut c = ctx();
560 c.task_state = TaskState {
561 goal: "g".to_string(),
562 ..Default::default()
563 };
564 c.signals.push("[ROLLBACK] tool failed".to_string());
565 let rc = render(&c, 10_000, &engine(), 4);
566 let state = rc.state_turn.as_ref().unwrap();
567 assert!(
568 state
569 .content
570 .as_text()
571 .unwrap()
572 .contains("[ROLLBACK] tool failed")
573 );
574 }
575
576 #[test]
577 fn empty_task_state_no_state_turn() {
578 let c = ctx();
579 let rc = render(&c, 10_000, &engine(), 4);
580 assert!(rc.state_turn.is_none());
582 assert!(rc.turns.is_empty());
583 }
584
585 #[test]
586 fn history_excludes_state_turn() {
587 let mut c = ctx();
588 c.task_state = TaskState {
589 goal: "g".to_string(),
590 ..Default::default()
591 };
592 c.history.push(Message::user("step 1"), 5);
593 c.history.push(Message::assistant("done"), 5);
594 let rc = render(&c, 10_000, &engine(), 4);
595 assert!(
597 rc.state_turn
598 .as_ref()
599 .unwrap()
600 .content
601 .as_text()
602 .unwrap()
603 .contains("[TASK STATE]")
604 );
605 assert_eq!(rc.turns[0].role, Role::User);
606 assert_eq!(rc.turns[0].content.as_text(), Some("step 1"));
607 assert_eq!(rc.turns[1].role, Role::Assistant);
608 }
609
610 #[test]
611 fn all_assistant_tool_history_gets_anchor_user_turn() {
612 let mut c = ctx();
613 c.history.push(Message::assistant("reply"), 5);
614 let rc = render(&c, 10_000, &engine(), 4);
615 assert_eq!(rc.turns[0].role, Role::User);
616 }
617
618 #[test]
619 fn zero_token_messages_skipped() {
620 let mut c = ctx();
621 c.history.push(Message::user("zero"), 0);
622 c.history.push(Message::user("real"), 5);
623 let rc = render(&c, 10_000, &engine(), 4);
624 assert!(rc.turns.iter().any(|m| m.content.as_text() == Some("real")));
626 assert!(!rc.turns.iter().any(|m| m.content.as_text() == Some("zero")));
627 }
628
629 #[test]
630 fn collapsed_tool_result_renders_as_preview_without_mutating_history() {
631 use crate::mm::handle::{Handle, HandleKind, HandleTable, Residency};
632
633 let mut c = ctx();
634 let long = "DATA ".repeat(200); c.history.push(
636 Message::tool(vec![ContentPart::ToolResult {
637 call_id: "c1".into(),
638 output: long.clone(),
639 is_error: false,
640 }]),
641 250,
642 );
643
644 let mut handles = HandleTable::new();
645 let mut h = Handle::resident_for(1, HandleKind::ToolResult, 250, "c1");
646 h.residency = Residency::Collapsed;
647 handles.insert(h);
648
649 let rc = render_projected(&c, 10_000, &engine(), 4, &handles, 0, false);
650 let rendered: String = rc
651 .turns
652 .iter()
653 .flat_map(|m| match &m.content {
654 Content::Parts(parts) => parts.clone(),
655 _ => Vec::new(),
656 })
657 .find_map(|p| match p {
658 ContentPart::ToolResult { output, .. } => Some(output),
659 _ => None,
660 })
661 .expect("tool result rendered");
662 assert!(rendered.contains("[Output truncated:"));
664 assert!(rendered.contains("read_result"));
665 assert!(rendered.contains("\"c1\""));
666 assert!(rendered.len() < long.len());
667 let stored = match &c.history.messages[0].content {
668 Content::Parts(parts) => match &parts[0] {
669 ContentPart::ToolResult { output, .. } => output.clone(),
670 _ => unreachable!(),
671 },
672 _ => unreachable!(),
673 };
674 assert_eq!(stored, long, "projection must not mutate stored history");
675 }
676
677 #[test]
678 fn resident_tool_result_renders_in_full() {
679 use crate::mm::handle::{Handle, HandleKind, HandleTable};
680
681 let mut c = ctx();
682 let body = "RESIDENT BODY ".repeat(20);
683 c.history.push(
684 Message::tool(vec![ContentPart::ToolResult {
685 call_id: "c2".into(),
686 output: body.clone(),
687 is_error: false,
688 }]),
689 60,
690 );
691 let mut handles = HandleTable::new();
692 handles.insert(Handle::resident_for(1, HandleKind::ToolResult, 60, "c2"));
693
694 let rc = render_projected(&c, 10_000, &engine(), 4, &handles, 0, false);
695 let rendered: String = rc
696 .turns
697 .iter()
698 .flat_map(|m| match &m.content {
699 Content::Parts(parts) => parts.clone(),
700 _ => Vec::new(),
701 })
702 .find_map(|p| match p {
703 ContentPart::ToolResult { output, .. } => Some(output),
704 _ => None,
705 })
706 .expect("tool result rendered");
707 assert_eq!(rendered, body);
708 assert!(!rendered.contains("[Output truncated:"));
709 }
710
711 #[test]
714 fn state_turn_footer_leads_with_next_step_not_bare_goal() {
715 let mut c = ctx();
716 c.task_state = TaskState {
717 goal: "ship the cache work".to_string(),
718 plan: vec![PlanStep {
719 label: "do E".to_string(),
720 done: false,
721 }],
722 current_step: Some(0),
723 ..Default::default()
724 };
725 c.task_state.record_directive("don't break ABI");
726 let rc = render(&c, 100_000, &engine(), 4);
727 let text = rc
728 .state_turn
729 .unwrap()
730 .content
731 .as_text()
732 .unwrap()
733 .to_string();
734
735 assert!(text.starts_with("[TASK STATE] goal: ship the cache work"));
737 let before_proceed = text
739 .rsplit_once("\n\nProceed.")
740 .expect("ends with Proceed")
741 .0;
742 let last_block = before_proceed.rsplit("\n\n").next().unwrap();
743 assert!(
744 last_block.starts_with("→ next: step 1 — do E"),
745 "got: {last_block}"
746 );
747 assert!(last_block.contains("must: don't break ABI"));
748 assert!(
750 !last_block.contains("focus: ship the cache work"),
751 "got: {last_block}"
752 );
753 }
754
755 #[test]
756 fn footer_falls_back_to_focus_goal_when_nothing_done_yet() {
757 let mut c = ctx();
759 c.task_state = TaskState {
760 goal: "build the thing".to_string(),
761 ..Default::default()
762 };
763 let rc = render(&c, 100_000, &engine(), 4);
764 let text = rc
765 .state_turn
766 .unwrap()
767 .content
768 .as_text()
769 .unwrap()
770 .to_string();
771 let footer = text
772 .rsplit_once("\n\nProceed.")
773 .unwrap()
774 .0
775 .rsplit("\n\n")
776 .next()
777 .unwrap();
778 assert_eq!(footer, "→ focus: build the thing");
779 }
780
781 #[test]
782 fn footer_shows_recent_actions_and_forward_nudge_without_a_plan() {
783 let mut c = ctx();
786 c.task_state = TaskState {
787 goal: "rebuild §4.4 as SVG".to_string(),
788 ..Default::default()
789 };
790 c.task_state.note_actions("module_list");
791 c.task_state.note_actions("module_read");
792 let rc = render(&c, 100_000, &engine(), 4);
793 let footer = rc
794 .state_turn
795 .unwrap()
796 .content
797 .as_text()
798 .unwrap()
799 .rsplit_once("\n\nProceed.")
800 .unwrap()
801 .0
802 .rsplit("\n\n")
803 .next()
804 .unwrap()
805 .to_string();
806 assert!(
807 footer.contains("did: module_list → module_read"),
808 "got: {footer}"
809 );
810 assert!(footer.contains("next: advance the goal"), "got: {footer}");
811 assert!(
812 !footer.contains("focus: rebuild §4.4 as SVG"),
813 "goal must not lead the footer"
814 );
815 }
816
817 #[test]
818 fn footer_raises_stop_on_repeated_action() {
819 let mut c = ctx();
821 c.task_state = TaskState {
822 goal: "g".to_string(),
823 ..Default::default()
824 };
825 c.task_state.note_actions("document_read");
826 c.task_state.note_actions("document_read");
827 c.task_state.note_actions("document_read");
828 let rc = render(&c, 100_000, &engine(), 4);
829 let footer = rc
830 .state_turn
831 .unwrap()
832 .content
833 .as_text()
834 .unwrap()
835 .rsplit_once("\n\nProceed.")
836 .unwrap()
837 .0
838 .rsplit("\n\n")
839 .next()
840 .unwrap()
841 .to_string();
842 assert!(
843 footer.contains("STOP: `document_read` repeated 3×"),
844 "got: {footer}"
845 );
846 }
847
848 #[test]
849 fn no_salience_footer_without_a_goal() {
850 let mut c = ctx();
851 c.signals.push("[ROLLBACK] tool failed".to_string());
852 let rc = render(&c, 100_000, &engine(), 4);
853 let text = rc
854 .state_turn
855 .unwrap()
856 .content
857 .as_text()
858 .unwrap()
859 .to_string();
860 assert!(!text.contains("→ focus:"), "no goal ⇒ no footer");
861 assert!(text.contains("[ROLLBACK] tool failed"));
863 }
864
865 #[test]
868 fn prefix_fingerprint_is_stable_when_appending_history() {
869 let mut c = ctx();
870 c.system.push(Message::system("rules"), 5);
871 c.knowledge.push(Message::system("skill: debug"), 5);
872 c.history.push(Message::user("turn A"), 5);
873 c.history.push(Message::assistant("turn B"), 5);
874 let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
875
876 c.history.push(Message::user("turn C"), 5);
878 let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
879
880 assert!(
881 fp2.extends(&fp1),
882 "appending must only grow the tail, never drift the prefix"
883 );
884 assert_eq!(
885 fp2.common_turn_prefix(&fp1),
886 2,
887 "both prior turns stay cache-reusable"
888 );
889 assert_eq!(fp2.turn_hashes.len(), 3);
890 }
891
892 #[test]
893 fn prefix_fingerprint_ignores_state_turn() {
894 let mut c = ctx();
897 c.history.push(Message::user("turn A"), 5);
898 c.task_state = TaskState {
899 goal: "first goal".to_string(),
900 ..Default::default()
901 };
902 let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
903
904 c.task_state = TaskState {
905 goal: "totally different goal".to_string(),
906 ..Default::default()
907 };
908 c.signals.push("[ROLLBACK] whatever".to_string());
909 let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
910
911 assert_eq!(
912 fp1, fp2,
913 "volatile state must not perturb the cacheable prefix"
914 );
915 }
916
917 #[test]
918 fn prefix_fingerprint_detects_system_drift() {
919 let mut c = ctx();
920 c.system.push(Message::system("rules v1"), 5);
921 c.history.push(Message::user("turn A"), 5);
922 let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
923
924 c.system.messages.clear();
925 c.system.push(Message::system("rules v2"), 5);
926 let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
927
928 assert_ne!(fp1.system_stable_hash, fp2.system_stable_hash);
929 assert!(
930 !fp2.extends(&fp1),
931 "a system-block edit invalidates the whole prefix"
932 );
933 }
934
935 #[test]
936 fn prefix_fingerprint_detects_in_place_collapse_churn() {
937 use crate::mm::handle::{Handle, HandleKind, HandleTable, Residency};
938
939 let mut c = ctx();
940 c.history.push(Message::user("start"), 5);
941 let long = "DATA ".repeat(200);
942 c.history.push(
943 Message::tool(vec![ContentPart::ToolResult {
944 call_id: "c1".into(),
945 output: long,
946 is_error: false,
947 }]),
948 250,
949 );
950 c.history.push(Message::user("recent"), 5);
951
952 let resident = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
953
954 let mut handles = HandleTable::new();
957 let mut h = Handle::resident_for(1, HandleKind::ToolResult, 250, "c1");
958 h.residency = Residency::Collapsed;
959 handles.insert(h);
960 let collapsed =
961 render_projected(&c, 100_000, &engine(), 4, &handles, 0, false).prefix_fingerprint();
962
963 assert_eq!(
965 collapsed.common_turn_prefix(&resident),
966 1,
967 "drift begins at the collapsed turn"
968 );
969 assert!(!collapsed.extends(&resident));
970 }
971
972 fn assistant_with_call(text: &str) -> Message {
975 let mut m = Message::assistant(text);
976 m.tool_calls = vec![crate::types::message::ToolCall {
977 id: "c1".into(),
978 name: "module_read".into(),
979 arguments: serde_json::json!({}),
980 }];
981 m
982 }
983
984 #[test]
985 fn old_assistant_narration_collapses_keeping_tool_calls() {
986 let mut c = ctx();
987 c.history.push(assistant_with_call(&"好的,我来将 §4.4 的 Mermaid 部署架构图重新构建为 SVG 版本。先找到当前 Mermaid 模块的位置。".repeat(1)), 60);
989 c.history.push(
990 Message::tool(vec![ContentPart::ToolResult {
991 call_id: "c1".into(),
992 output: "located".into(),
993 is_error: false,
994 }]),
995 2,
996 );
997 for i in 0..5 {
998 c.history.push(Message::user(format!("recent {i}")), 5);
999 }
1000
1001 let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
1003 let narration = rc
1004 .turns
1005 .iter()
1006 .find(|m| m.content.as_text() == Some(NARRATION_STUB))
1007 .expect("old narration replaced by stub");
1008 assert_eq!(
1009 narration.tool_calls.len(),
1010 1,
1011 "tool call (pairing) preserved"
1012 );
1013 assert_eq!(narration.tool_calls[0].name, "module_read");
1014 assert!(!rc.turns.iter().any(|m| {
1016 m.content
1017 .as_text()
1018 .map(|t| t.contains("先找到当前 Mermaid"))
1019 .unwrap_or(false)
1020 }));
1021 assert!(
1023 c.history.messages[0]
1024 .content
1025 .as_text()
1026 .unwrap()
1027 .contains("先找到当前 Mermaid")
1028 );
1029
1030 let rc_off = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, false);
1032 assert!(rc_off.turns.iter().any(|m| {
1033 m.content
1034 .as_text()
1035 .map(|t| t.contains("先找到当前 Mermaid"))
1036 .unwrap_or(false)
1037 }));
1038 }
1039
1040 #[test]
1041 fn recent_assistant_narration_within_window_is_not_collapsed() {
1042 let mut c = ctx();
1043 c.history.push(
1045 assistant_with_call(
1046 &"好的,我来将 §4.4 重新构建为 SVG。先定位模块位置确认范围读取内容。".to_string(),
1047 ),
1048 60,
1049 );
1050 c.history.push(
1051 Message::tool(vec![ContentPart::ToolResult {
1052 call_id: "c1".into(),
1053 output: "located".into(),
1054 is_error: false,
1055 }]),
1056 2,
1057 );
1058 c.history.push(Message::user("ok"), 5);
1059 let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
1060 assert!(
1061 rc.turns.iter().any(|m| m
1062 .content
1063 .as_text()
1064 .map(|t| t.contains("先定位模块位置"))
1065 .unwrap_or(false)),
1066 "recent narration kept verbatim"
1067 );
1068 }
1069
1070 #[test]
1071 fn assistant_without_tool_calls_is_never_collapsed() {
1072 let mut c = ctx();
1073 c.history.push(
1075 Message::assistant("这是给用户的最终结论,包含实质内容,不应被折叠掉以免丢信息。"),
1076 40,
1077 );
1078 for i in 0..5 {
1079 c.history.push(Message::user(format!("r{i}")), 5);
1080 }
1081 let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
1082 assert!(
1083 rc.turns.iter().any(|m| m
1084 .content
1085 .as_text()
1086 .map(|t| t.contains("最终结论"))
1087 .unwrap_or(false)),
1088 "answer-only turns are not narration"
1089 );
1090 }
1091
1092 #[test]
1093 fn collapsing_narration_drifts_only_that_turn_in_the_cache_prefix() {
1094 let mut c = ctx();
1097 c.history.push(Message::user("start"), 5);
1098 c.history.push(assistant_with_call(&"好的,我来将 §4.4 重新构建为 SVG 版本。先找到 Mermaid 模块的确切位置再读取其内容。".to_string()), 60);
1099 c.history.push(
1100 Message::tool(vec![ContentPart::ToolResult {
1101 call_id: "c1".into(),
1102 output: "located".into(),
1103 is_error: false,
1104 }]),
1105 2,
1106 );
1107 for i in 0..4 {
1108 c.history.push(Message::user(format!("recent {i}")), 5);
1109 }
1110
1111 let verbatim = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, false)
1112 .prefix_fingerprint();
1113 let collapsed = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true)
1114 .prefix_fingerprint();
1115 assert_eq!(
1117 collapsed.common_turn_prefix(&verbatim),
1118 1,
1119 "only the collapsed turn drifts"
1120 );
1121 assert!(!collapsed.extends(&verbatim));
1122 }
1123
1124 #[test]
1125 fn protected_recent_messages_kept_whole_over_budget() {
1126 let mut c = ctx();
1127 c.history.push(Message::user("first message"), 5);
1128 c.history.push(Message::user("a".repeat(1000)), 250);
1129 let rc = render(&c, 10, &engine(), 2);
1131 assert!(rc.turns.iter().any(|m| {
1132 m.content
1133 .as_text()
1134 .map(|t| t.contains("first message"))
1135 .unwrap_or(false)
1136 }));
1137 }
1138
1139 #[test]
1140 fn render_drops_or_keeps_tool_transactions_as_complete_units() {
1141 let mut c = ctx();
1142 c.history.push(Message::user("old"), 10);
1143 c.history.push(Message::assistant("old answer"), 10);
1144 let mut call = Message::assistant("calling");
1145 call.tool_calls.push(crate::types::message::ToolCall {
1146 id: "call-1".into(),
1147 name: "read".into(),
1148 arguments: serde_json::json!({}),
1149 });
1150 c.history.push(Message::user("question"), 10);
1151 c.history.push(call, 10);
1152 c.history.push(
1153 Message::tool(vec![crate::types::message::ContentPart::ToolResult {
1154 call_id: "call-1".into(),
1155 output: "ok".into(),
1156 is_error: false,
1157 }]),
1158 10,
1159 );
1160 c.history.push(Message::assistant("answer"), 10);
1161
1162 let rc = render(&c, 25, &engine(), 1);
1163
1164 assert_eq!(rc.turns.len(), 4);
1165 assert_eq!(rc.turns[0].content.as_text(), Some("question"));
1166 assert_eq!(rc.turns[3].content.as_text(), Some("answer"));
1167 }
1168
1169 #[test]
1170 fn oversized_text_boundary_is_dropped_whole_not_truncated() {
1171 let mut c = ctx();
1174 c.history.push(Message::user("a".repeat(1000)), 250); c.history.push(Message::user("recent"), 2); let rc = render(&c, 5, &engine(), 0); assert_eq!(rc.turns.len(), 1, "only the fitting newest turn survives");
1178 assert_eq!(rc.turns[0].content.as_text(), Some("recent"));
1179 assert!(
1180 !rc.turns.iter().any(|m| m
1181 .content
1182 .as_text()
1183 .map(|t| t.starts_with("aaaa"))
1184 .unwrap_or(false)),
1185 "no truncated body in the prefix"
1186 );
1187 }
1188
1189 #[test]
1190 fn state_turn_is_budgeted_before_history() {
1191 let mut c = ctx();
1192 c.task_state = TaskState {
1193 goal: "keep the state".to_string(),
1194 ..Default::default()
1195 };
1196 c.history.push(Message::user("x".repeat(120)), 30);
1197 let state_tokens = engine().count_message(&build_state_turn(&c).expect("state"));
1198
1199 let rc = render(&c, state_tokens + 5, &engine(), 0);
1200
1201 assert!(
1202 rc.turns.is_empty(),
1203 "history must not consume the state reservation"
1204 );
1205 assert!(rc.budget_overflow.is_none());
1206 }
1207
1208 #[test]
1209 fn protected_tail_overflow_is_reported_instead_of_hidden() {
1210 let mut c = ctx();
1211 c.history.push(Message::user("x".repeat(400)), 100);
1212
1213 let rc = render(&c, 10, &engine(), 2);
1214
1215 let overflow = rc
1216 .budget_overflow
1217 .expect("protected tail overflow must be explicit");
1218 assert_eq!(overflow.kind, ContextBudgetOverflowKind::ProtectedTail);
1219 assert!(overflow.required_tokens > overflow.max_tokens);
1220 }
1221
1222 #[test]
1223 fn fixed_context_overflow_is_reported_with_actual_token_count() {
1224 let mut c = ctx();
1225 c.system.push(Message::system("x".repeat(400)), 100);
1226
1227 let rc = render(&c, 10, &engine(), 0);
1228
1229 let overflow = rc
1230 .budget_overflow
1231 .expect("fixed context overflow must be explicit");
1232 assert_eq!(overflow.kind, ContextBudgetOverflowKind::FixedContext);
1233 assert_eq!(overflow.required_tokens, 100);
1234 assert_eq!(overflow.max_tokens, 10);
1235 }
1236}