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) -> String {
260 const PREVIEW_BYTES: usize = 160;
261 let mut end = PREVIEW_BYTES.min(output.len());
262 while end > 0 && !output.is_char_boundary(end) {
263 end -= 1;
264 }
265 let dropped = output.len().saturating_sub(end);
266 format!(
267 "{}…\n[collapsed: {dropped} chars projected out of view; full result retained in history]",
268 &output[..end]
269 )
270}
271
272const NARRATION_STUB: &str = "[earlier narration collapsed; tool call(s) preserved below — current progress is in the TASK STATE block]";
275
276const NARRATION_COLLAPSE_MIN_CHARS: usize = 40;
279
280fn project_assistant_narration(msg: &Message, enabled: bool) -> Option<Message> {
288 if !enabled || msg.role != Role::Assistant || msg.tool_calls.is_empty() {
289 return None;
290 }
291 let Content::Text(text) = &msg.content else {
292 return None;
293 };
294 if text == NARRATION_STUB || text.chars().count() < NARRATION_COLLAPSE_MIN_CHARS {
295 return None;
296 }
297 let mut projected = msg.clone();
298 projected.content = Content::Text(NARRATION_STUB.to_string());
299 projected.token_count = None; Some(projected)
301}
302
303fn project_message(msg: &Message, handles: &HandleTable) -> Option<Message> {
306 let Content::Parts(parts) = &msg.content else {
307 return None;
308 };
309 let mut changed = false;
310 let new_parts: Vec<ContentPart> = parts
311 .iter()
312 .map(|part| match part {
313 ContentPart::ToolResult {
314 call_id,
315 output,
316 is_error,
317 } if matches!(
318 handles.residency_for_source(call_id),
319 Some(Residency::Collapsed)
320 ) =>
321 {
322 changed = true;
323 ContentPart::ToolResult {
324 call_id: call_id.clone(),
325 output: collapse_preview(output),
326 is_error: *is_error,
327 }
328 }
329 other => other.clone(),
330 })
331 .collect();
332 if changed {
333 let mut projected = msg.clone();
334 projected.content = Content::Parts(new_parts);
335 projected.token_count = None; Some(projected)
337 } else {
338 None
339 }
340}
341
342#[cfg(test)]
348pub(crate) fn render(
349 partitions: &ContextPartitions,
350 budget: u32,
351 engine: &ContextTokenEngine,
352 preserve_recent_units: usize,
353) -> RenderedContext {
354 render_projected(
357 partitions,
358 budget,
359 engine,
360 preserve_recent_units,
361 &HandleTable::new(),
362 0,
363 false,
364 )
365}
366
367pub fn render_projected(
376 partitions: &ContextPartitions,
377 budget: u32,
378 engine: &ContextTokenEngine,
379 preserve_recent_units: usize,
380 handles: &HandleTable,
381 frozen_history_len: usize,
382 collapse_narration: bool,
383) -> RenderedContext {
384 let system_stable = build_system_stable(partitions);
385 let system_knowledge = build_system_knowledge(partitions);
386 let system_text = [system_stable.as_str(), system_knowledge.as_str()]
387 .iter()
388 .filter(|s| !s.is_empty())
389 .cloned()
390 .collect::<Vec<_>>()
391 .join("\n\n");
392
393 let system_tokens = engine.count(&system_text);
396 let state_turn = build_state_turn(partitions);
397 let state_tokens = state_turn
398 .as_ref()
399 .map_or(0, |message| engine.count_message(message));
400 let fixed_tokens = system_tokens.saturating_add(state_tokens);
401 let mut remaining = budget.saturating_sub(fixed_tokens);
402 let mut used_tokens = fixed_tokens;
403 let mut budget_overflow = (fixed_tokens > budget).then_some(ContextBudgetOverflow {
404 kind: ContextBudgetOverflowKind::FixedContext,
405 required_tokens: fixed_tokens,
406 max_tokens: budget,
407 });
408
409 let units = unit_boundaries(&partitions.history.messages);
410 let protected_from = units.len().saturating_sub(preserve_recent_units);
411 let mut kept_units_rev: Vec<Vec<Message>> = Vec::new();
412
413 for (unit_index, unit) in units.iter().enumerate().rev() {
414 let is_protected = unit_index >= protected_from;
415 let effective = partitions.history.messages[unit.clone()]
416 .iter()
417 .map(|msg| {
418 project_message(msg, handles)
419 .or_else(|| {
420 if is_protected {
421 None
422 } else {
423 project_assistant_narration(msg, collapse_narration)
424 }
425 })
426 .unwrap_or_else(|| msg.clone())
427 })
428 .collect::<Vec<_>>();
429 let tokens = effective
430 .iter()
431 .map(|msg| msg.token_count.unwrap_or_else(|| engine.count_message(msg)))
432 .sum::<u32>();
433 if tokens == 0 {
434 continue;
435 }
436
437 if is_protected || tokens <= remaining {
438 kept_units_rev.push(effective);
439 remaining = remaining.saturating_sub(tokens);
440 used_tokens = used_tokens.saturating_add(tokens);
441 if is_protected && used_tokens > budget && budget_overflow.is_none() {
442 budget_overflow = Some(ContextBudgetOverflow {
443 kind: ContextBudgetOverflowKind::ProtectedTail,
444 required_tokens: used_tokens,
445 max_tokens: budget,
446 });
447 }
448 } else {
449 break;
450 }
451 }
452
453 kept_units_rev.reverse();
454 let mut turns = kept_units_rev.into_iter().flatten().collect::<Vec<_>>();
455 normalize_turn_prefix(&mut turns);
456 debug_assert!(
457 !strict_tool_pairing_is_valid(&partitions.history.messages)
458 || strict_tool_pairing_is_valid(&turns),
459 "renderer split a valid tool transaction"
460 );
461
462 let hot = partitions
469 .history
470 .messages
471 .len()
472 .saturating_sub(frozen_history_len);
473 let frozen_prefix_len = if frozen_history_len > 0 && hot > 0 && hot < turns.len() {
474 Some(turns.len() - hot)
475 } else {
476 None
477 };
478
479 RenderedContext {
480 system_text,
481 system_stable,
482 system_knowledge,
483 turns,
484 state_turn,
485 frozen_prefix_len,
486 budget_overflow,
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use crate::context::config::ContextConfig;
494 use crate::context::partitions::ContextPartitions;
495 use crate::context::task_state::{PlanStep, TaskState};
496 use crate::context::token_engine::ContextTokenEngine;
497 use crate::types::message::{Message, Role};
498
499 fn engine() -> ContextTokenEngine {
500 ContextTokenEngine::char_approx()
501 }
502 fn ctx() -> ContextPartitions {
503 ContextPartitions::new(&ContextConfig::default())
504 }
505
506 #[test]
507 fn system_stable_contains_system_partition() {
508 let mut c = ctx();
509 c.system.push(Message::system("You are helpful."), 10);
510 let rc = render(&c, 10_000, &engine(), 4);
511 assert!(rc.system_stable.contains("You are helpful."));
512 assert!(rc.system_text.contains("You are helpful."));
513 }
514
515 #[test]
516 fn system_knowledge_contains_knowledge_partition() {
517 let mut c = ctx();
518 c.knowledge.push(Message::system("skill: debug"), 10);
519 let rc = render(&c, 10_000, &engine(), 4);
520 assert!(rc.system_knowledge.contains("skill: debug"));
521 assert!(rc.system_text.contains("skill: debug"));
522 }
523
524 #[test]
525 fn task_state_appears_in_state_turn() {
526 let mut c = ctx();
527 c.task_state = TaskState {
528 goal: "find the bug".to_string(),
529 ..Default::default()
530 };
531 let rc = render(&c, 10_000, &engine(), 4);
532 assert!(
533 !rc.system_text.contains("[TASK STATE]"),
534 "task_state must not be in system_text"
535 );
536 let state = rc.state_turn.as_ref().expect("should have a state turn");
537 assert_eq!(state.role, Role::User);
538 assert!(
539 state
540 .content
541 .as_text()
542 .unwrap()
543 .contains("[TASK STATE] goal: find the bug")
544 );
545 assert!(!rc.turns.iter().any(|m| {
547 m.content
548 .as_text()
549 .map(|t| t.contains("[TASK STATE]"))
550 .unwrap_or(false)
551 }));
552 }
553
554 #[test]
555 fn signals_appear_in_state_turn() {
556 let mut c = ctx();
557 c.task_state = TaskState {
558 goal: "g".to_string(),
559 ..Default::default()
560 };
561 c.signals.push("[ROLLBACK] tool failed".to_string());
562 let rc = render(&c, 10_000, &engine(), 4);
563 let state = rc.state_turn.as_ref().unwrap();
564 assert!(
565 state
566 .content
567 .as_text()
568 .unwrap()
569 .contains("[ROLLBACK] tool failed")
570 );
571 }
572
573 #[test]
574 fn empty_task_state_no_state_turn() {
575 let c = ctx();
576 let rc = render(&c, 10_000, &engine(), 4);
577 assert!(rc.state_turn.is_none());
579 assert!(rc.turns.is_empty());
580 }
581
582 #[test]
583 fn history_excludes_state_turn() {
584 let mut c = ctx();
585 c.task_state = TaskState {
586 goal: "g".to_string(),
587 ..Default::default()
588 };
589 c.history.push(Message::user("step 1"), 5);
590 c.history.push(Message::assistant("done"), 5);
591 let rc = render(&c, 10_000, &engine(), 4);
592 assert!(
594 rc.state_turn
595 .as_ref()
596 .unwrap()
597 .content
598 .as_text()
599 .unwrap()
600 .contains("[TASK STATE]")
601 );
602 assert_eq!(rc.turns[0].role, Role::User);
603 assert_eq!(rc.turns[0].content.as_text(), Some("step 1"));
604 assert_eq!(rc.turns[1].role, Role::Assistant);
605 }
606
607 #[test]
608 fn all_assistant_tool_history_gets_anchor_user_turn() {
609 let mut c = ctx();
610 c.history.push(Message::assistant("reply"), 5);
611 let rc = render(&c, 10_000, &engine(), 4);
612 assert_eq!(rc.turns[0].role, Role::User);
613 }
614
615 #[test]
616 fn zero_token_messages_skipped() {
617 let mut c = ctx();
618 c.history.push(Message::user("zero"), 0);
619 c.history.push(Message::user("real"), 5);
620 let rc = render(&c, 10_000, &engine(), 4);
621 assert!(rc.turns.iter().any(|m| m.content.as_text() == Some("real")));
623 assert!(!rc.turns.iter().any(|m| m.content.as_text() == Some("zero")));
624 }
625
626 #[test]
627 fn collapsed_tool_result_renders_as_preview_without_mutating_history() {
628 use crate::mm::handle::{Handle, HandleKind, HandleTable, Residency};
629
630 let mut c = ctx();
631 let long = "DATA ".repeat(200); c.history.push(
633 Message::tool(vec![ContentPart::ToolResult {
634 call_id: "c1".into(),
635 output: long.clone(),
636 is_error: false,
637 }]),
638 250,
639 );
640
641 let mut handles = HandleTable::new();
642 let mut h = Handle::resident_for(1, HandleKind::ToolResult, 250, "c1");
643 h.residency = Residency::Collapsed;
644 handles.insert(h);
645
646 let rc = render_projected(&c, 10_000, &engine(), 4, &handles, 0, false);
647 let rendered: String = rc
648 .turns
649 .iter()
650 .flat_map(|m| match &m.content {
651 Content::Parts(parts) => parts.clone(),
652 _ => Vec::new(),
653 })
654 .find_map(|p| match p {
655 ContentPart::ToolResult { output, .. } => Some(output),
656 _ => None,
657 })
658 .expect("tool result rendered");
659 assert!(rendered.contains("[collapsed:"));
661 assert!(rendered.len() < long.len());
662 let stored = match &c.history.messages[0].content {
663 Content::Parts(parts) => match &parts[0] {
664 ContentPart::ToolResult { output, .. } => output.clone(),
665 _ => unreachable!(),
666 },
667 _ => unreachable!(),
668 };
669 assert_eq!(stored, long, "projection must not mutate stored history");
670 }
671
672 #[test]
673 fn resident_tool_result_renders_in_full() {
674 use crate::mm::handle::{Handle, HandleKind, HandleTable};
675
676 let mut c = ctx();
677 let body = "RESIDENT BODY ".repeat(20);
678 c.history.push(
679 Message::tool(vec![ContentPart::ToolResult {
680 call_id: "c2".into(),
681 output: body.clone(),
682 is_error: false,
683 }]),
684 60,
685 );
686 let mut handles = HandleTable::new();
687 handles.insert(Handle::resident_for(1, HandleKind::ToolResult, 60, "c2"));
688
689 let rc = render_projected(&c, 10_000, &engine(), 4, &handles, 0, false);
690 let rendered: String = rc
691 .turns
692 .iter()
693 .flat_map(|m| match &m.content {
694 Content::Parts(parts) => parts.clone(),
695 _ => Vec::new(),
696 })
697 .find_map(|p| match p {
698 ContentPart::ToolResult { output, .. } => Some(output),
699 _ => None,
700 })
701 .expect("tool result rendered");
702 assert_eq!(rendered, body);
703 assert!(!rendered.contains("[collapsed:"));
704 }
705
706 #[test]
709 fn state_turn_footer_leads_with_next_step_not_bare_goal() {
710 let mut c = ctx();
711 c.task_state = TaskState {
712 goal: "ship the cache work".to_string(),
713 plan: vec![PlanStep {
714 label: "do E".to_string(),
715 done: false,
716 }],
717 current_step: Some(0),
718 ..Default::default()
719 };
720 c.task_state.record_directive("don't break ABI");
721 let rc = render(&c, 100_000, &engine(), 4);
722 let text = rc
723 .state_turn
724 .unwrap()
725 .content
726 .as_text()
727 .unwrap()
728 .to_string();
729
730 assert!(text.starts_with("[TASK STATE] goal: ship the cache work"));
732 let before_proceed = text
734 .rsplit_once("\n\nProceed.")
735 .expect("ends with Proceed")
736 .0;
737 let last_block = before_proceed.rsplit("\n\n").next().unwrap();
738 assert!(
739 last_block.starts_with("→ next: step 1 — do E"),
740 "got: {last_block}"
741 );
742 assert!(last_block.contains("must: don't break ABI"));
743 assert!(
745 !last_block.contains("focus: ship the cache work"),
746 "got: {last_block}"
747 );
748 }
749
750 #[test]
751 fn footer_falls_back_to_focus_goal_when_nothing_done_yet() {
752 let mut c = ctx();
754 c.task_state = TaskState {
755 goal: "build the thing".to_string(),
756 ..Default::default()
757 };
758 let rc = render(&c, 100_000, &engine(), 4);
759 let text = rc
760 .state_turn
761 .unwrap()
762 .content
763 .as_text()
764 .unwrap()
765 .to_string();
766 let footer = text
767 .rsplit_once("\n\nProceed.")
768 .unwrap()
769 .0
770 .rsplit("\n\n")
771 .next()
772 .unwrap();
773 assert_eq!(footer, "→ focus: build the thing");
774 }
775
776 #[test]
777 fn footer_shows_recent_actions_and_forward_nudge_without_a_plan() {
778 let mut c = ctx();
781 c.task_state = TaskState {
782 goal: "rebuild §4.4 as SVG".to_string(),
783 ..Default::default()
784 };
785 c.task_state.note_actions("module_list");
786 c.task_state.note_actions("module_read");
787 let rc = render(&c, 100_000, &engine(), 4);
788 let footer = rc
789 .state_turn
790 .unwrap()
791 .content
792 .as_text()
793 .unwrap()
794 .rsplit_once("\n\nProceed.")
795 .unwrap()
796 .0
797 .rsplit("\n\n")
798 .next()
799 .unwrap()
800 .to_string();
801 assert!(
802 footer.contains("did: module_list → module_read"),
803 "got: {footer}"
804 );
805 assert!(footer.contains("next: advance the goal"), "got: {footer}");
806 assert!(
807 !footer.contains("focus: rebuild §4.4 as SVG"),
808 "goal must not lead the footer"
809 );
810 }
811
812 #[test]
813 fn footer_raises_stop_on_repeated_action() {
814 let mut c = ctx();
816 c.task_state = TaskState {
817 goal: "g".to_string(),
818 ..Default::default()
819 };
820 c.task_state.note_actions("document_read");
821 c.task_state.note_actions("document_read");
822 c.task_state.note_actions("document_read");
823 let rc = render(&c, 100_000, &engine(), 4);
824 let footer = rc
825 .state_turn
826 .unwrap()
827 .content
828 .as_text()
829 .unwrap()
830 .rsplit_once("\n\nProceed.")
831 .unwrap()
832 .0
833 .rsplit("\n\n")
834 .next()
835 .unwrap()
836 .to_string();
837 assert!(
838 footer.contains("STOP: `document_read` repeated 3×"),
839 "got: {footer}"
840 );
841 }
842
843 #[test]
844 fn no_salience_footer_without_a_goal() {
845 let mut c = ctx();
846 c.signals.push("[ROLLBACK] tool failed".to_string());
847 let rc = render(&c, 100_000, &engine(), 4);
848 let text = rc
849 .state_turn
850 .unwrap()
851 .content
852 .as_text()
853 .unwrap()
854 .to_string();
855 assert!(!text.contains("→ focus:"), "no goal ⇒ no footer");
856 assert!(text.contains("[ROLLBACK] tool failed"));
858 }
859
860 #[test]
863 fn prefix_fingerprint_is_stable_when_appending_history() {
864 let mut c = ctx();
865 c.system.push(Message::system("rules"), 5);
866 c.knowledge.push(Message::system("skill: debug"), 5);
867 c.history.push(Message::user("turn A"), 5);
868 c.history.push(Message::assistant("turn B"), 5);
869 let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
870
871 c.history.push(Message::user("turn C"), 5);
873 let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
874
875 assert!(
876 fp2.extends(&fp1),
877 "appending must only grow the tail, never drift the prefix"
878 );
879 assert_eq!(
880 fp2.common_turn_prefix(&fp1),
881 2,
882 "both prior turns stay cache-reusable"
883 );
884 assert_eq!(fp2.turn_hashes.len(), 3);
885 }
886
887 #[test]
888 fn prefix_fingerprint_ignores_state_turn() {
889 let mut c = ctx();
892 c.history.push(Message::user("turn A"), 5);
893 c.task_state = TaskState {
894 goal: "first goal".to_string(),
895 ..Default::default()
896 };
897 let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
898
899 c.task_state = TaskState {
900 goal: "totally different goal".to_string(),
901 ..Default::default()
902 };
903 c.signals.push("[ROLLBACK] whatever".to_string());
904 let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
905
906 assert_eq!(
907 fp1, fp2,
908 "volatile state must not perturb the cacheable prefix"
909 );
910 }
911
912 #[test]
913 fn prefix_fingerprint_detects_system_drift() {
914 let mut c = ctx();
915 c.system.push(Message::system("rules v1"), 5);
916 c.history.push(Message::user("turn A"), 5);
917 let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
918
919 c.system.messages.clear();
920 c.system.push(Message::system("rules v2"), 5);
921 let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
922
923 assert_ne!(fp1.system_stable_hash, fp2.system_stable_hash);
924 assert!(
925 !fp2.extends(&fp1),
926 "a system-block edit invalidates the whole prefix"
927 );
928 }
929
930 #[test]
931 fn prefix_fingerprint_detects_in_place_collapse_churn() {
932 use crate::mm::handle::{Handle, HandleKind, HandleTable, Residency};
933
934 let mut c = ctx();
935 c.history.push(Message::user("start"), 5);
936 let long = "DATA ".repeat(200);
937 c.history.push(
938 Message::tool(vec![ContentPart::ToolResult {
939 call_id: "c1".into(),
940 output: long,
941 is_error: false,
942 }]),
943 250,
944 );
945 c.history.push(Message::user("recent"), 5);
946
947 let resident = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
948
949 let mut handles = HandleTable::new();
952 let mut h = Handle::resident_for(1, HandleKind::ToolResult, 250, "c1");
953 h.residency = Residency::Collapsed;
954 handles.insert(h);
955 let collapsed =
956 render_projected(&c, 100_000, &engine(), 4, &handles, 0, false).prefix_fingerprint();
957
958 assert_eq!(
960 collapsed.common_turn_prefix(&resident),
961 1,
962 "drift begins at the collapsed turn"
963 );
964 assert!(!collapsed.extends(&resident));
965 }
966
967 fn assistant_with_call(text: &str) -> Message {
970 let mut m = Message::assistant(text);
971 m.tool_calls = vec![crate::types::message::ToolCall {
972 id: "c1".into(),
973 name: "module_read".into(),
974 arguments: serde_json::json!({}),
975 }];
976 m
977 }
978
979 #[test]
980 fn old_assistant_narration_collapses_keeping_tool_calls() {
981 let mut c = ctx();
982 c.history.push(assistant_with_call(&"好的,我来将 §4.4 的 Mermaid 部署架构图重新构建为 SVG 版本。先找到当前 Mermaid 模块的位置。".repeat(1)), 60);
984 c.history.push(
985 Message::tool(vec![ContentPart::ToolResult {
986 call_id: "c1".into(),
987 output: "located".into(),
988 is_error: false,
989 }]),
990 2,
991 );
992 for i in 0..5 {
993 c.history.push(Message::user(format!("recent {i}")), 5);
994 }
995
996 let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
998 let narration = rc
999 .turns
1000 .iter()
1001 .find(|m| m.content.as_text() == Some(NARRATION_STUB))
1002 .expect("old narration replaced by stub");
1003 assert_eq!(
1004 narration.tool_calls.len(),
1005 1,
1006 "tool call (pairing) preserved"
1007 );
1008 assert_eq!(narration.tool_calls[0].name, "module_read");
1009 assert!(!rc.turns.iter().any(|m| {
1011 m.content
1012 .as_text()
1013 .map(|t| t.contains("先找到当前 Mermaid"))
1014 .unwrap_or(false)
1015 }));
1016 assert!(
1018 c.history.messages[0]
1019 .content
1020 .as_text()
1021 .unwrap()
1022 .contains("先找到当前 Mermaid")
1023 );
1024
1025 let rc_off = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, false);
1027 assert!(rc_off.turns.iter().any(|m| {
1028 m.content
1029 .as_text()
1030 .map(|t| t.contains("先找到当前 Mermaid"))
1031 .unwrap_or(false)
1032 }));
1033 }
1034
1035 #[test]
1036 fn recent_assistant_narration_within_window_is_not_collapsed() {
1037 let mut c = ctx();
1038 c.history.push(
1040 assistant_with_call(
1041 &"好的,我来将 §4.4 重新构建为 SVG。先定位模块位置确认范围读取内容。".to_string(),
1042 ),
1043 60,
1044 );
1045 c.history.push(
1046 Message::tool(vec![ContentPart::ToolResult {
1047 call_id: "c1".into(),
1048 output: "located".into(),
1049 is_error: false,
1050 }]),
1051 2,
1052 );
1053 c.history.push(Message::user("ok"), 5);
1054 let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
1055 assert!(
1056 rc.turns.iter().any(|m| m
1057 .content
1058 .as_text()
1059 .map(|t| t.contains("先定位模块位置"))
1060 .unwrap_or(false)),
1061 "recent narration kept verbatim"
1062 );
1063 }
1064
1065 #[test]
1066 fn assistant_without_tool_calls_is_never_collapsed() {
1067 let mut c = ctx();
1068 c.history.push(
1070 Message::assistant("这是给用户的最终结论,包含实质内容,不应被折叠掉以免丢信息。"),
1071 40,
1072 );
1073 for i in 0..5 {
1074 c.history.push(Message::user(format!("r{i}")), 5);
1075 }
1076 let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
1077 assert!(
1078 rc.turns.iter().any(|m| m
1079 .content
1080 .as_text()
1081 .map(|t| t.contains("最终结论"))
1082 .unwrap_or(false)),
1083 "answer-only turns are not narration"
1084 );
1085 }
1086
1087 #[test]
1088 fn collapsing_narration_drifts_only_that_turn_in_the_cache_prefix() {
1089 let mut c = ctx();
1092 c.history.push(Message::user("start"), 5);
1093 c.history.push(assistant_with_call(&"好的,我来将 §4.4 重新构建为 SVG 版本。先找到 Mermaid 模块的确切位置再读取其内容。".to_string()), 60);
1094 c.history.push(
1095 Message::tool(vec![ContentPart::ToolResult {
1096 call_id: "c1".into(),
1097 output: "located".into(),
1098 is_error: false,
1099 }]),
1100 2,
1101 );
1102 for i in 0..4 {
1103 c.history.push(Message::user(format!("recent {i}")), 5);
1104 }
1105
1106 let verbatim = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, false)
1107 .prefix_fingerprint();
1108 let collapsed = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true)
1109 .prefix_fingerprint();
1110 assert_eq!(
1112 collapsed.common_turn_prefix(&verbatim),
1113 1,
1114 "only the collapsed turn drifts"
1115 );
1116 assert!(!collapsed.extends(&verbatim));
1117 }
1118
1119 #[test]
1120 fn protected_recent_messages_kept_whole_over_budget() {
1121 let mut c = ctx();
1122 c.history.push(Message::user("first message"), 5);
1123 c.history.push(Message::user("a".repeat(1000)), 250);
1124 let rc = render(&c, 10, &engine(), 2);
1126 assert!(rc.turns.iter().any(|m| {
1127 m.content
1128 .as_text()
1129 .map(|t| t.contains("first message"))
1130 .unwrap_or(false)
1131 }));
1132 }
1133
1134 #[test]
1135 fn render_drops_or_keeps_tool_transactions_as_complete_units() {
1136 let mut c = ctx();
1137 c.history.push(Message::user("old"), 10);
1138 c.history.push(Message::assistant("old answer"), 10);
1139 let mut call = Message::assistant("calling");
1140 call.tool_calls.push(crate::types::message::ToolCall {
1141 id: "call-1".into(),
1142 name: "read".into(),
1143 arguments: serde_json::json!({}),
1144 });
1145 c.history.push(Message::user("question"), 10);
1146 c.history.push(call, 10);
1147 c.history.push(
1148 Message::tool(vec![crate::types::message::ContentPart::ToolResult {
1149 call_id: "call-1".into(),
1150 output: "ok".into(),
1151 is_error: false,
1152 }]),
1153 10,
1154 );
1155 c.history.push(Message::assistant("answer"), 10);
1156
1157 let rc = render(&c, 25, &engine(), 1);
1158
1159 assert_eq!(rc.turns.len(), 4);
1160 assert_eq!(rc.turns[0].content.as_text(), Some("question"));
1161 assert_eq!(rc.turns[3].content.as_text(), Some("answer"));
1162 }
1163
1164 #[test]
1165 fn oversized_text_boundary_is_dropped_whole_not_truncated() {
1166 let mut c = ctx();
1169 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");
1173 assert_eq!(rc.turns[0].content.as_text(), Some("recent"));
1174 assert!(
1175 !rc.turns.iter().any(|m| m
1176 .content
1177 .as_text()
1178 .map(|t| t.starts_with("aaaa"))
1179 .unwrap_or(false)),
1180 "no truncated body in the prefix"
1181 );
1182 }
1183
1184 #[test]
1185 fn state_turn_is_budgeted_before_history() {
1186 let mut c = ctx();
1187 c.task_state = TaskState {
1188 goal: "keep the state".to_string(),
1189 ..Default::default()
1190 };
1191 c.history.push(Message::user("x".repeat(120)), 30);
1192 let state_tokens = engine().count_message(&build_state_turn(&c).expect("state"));
1193
1194 let rc = render(&c, state_tokens + 5, &engine(), 0);
1195
1196 assert!(
1197 rc.turns.is_empty(),
1198 "history must not consume the state reservation"
1199 );
1200 assert!(rc.budget_overflow.is_none());
1201 }
1202
1203 #[test]
1204 fn protected_tail_overflow_is_reported_instead_of_hidden() {
1205 let mut c = ctx();
1206 c.history.push(Message::user("x".repeat(400)), 100);
1207
1208 let rc = render(&c, 10, &engine(), 2);
1209
1210 let overflow = rc
1211 .budget_overflow
1212 .expect("protected tail overflow must be explicit");
1213 assert_eq!(overflow.kind, ContextBudgetOverflowKind::ProtectedTail);
1214 assert!(overflow.required_tokens > overflow.max_tokens);
1215 }
1216
1217 #[test]
1218 fn fixed_context_overflow_is_reported_with_actual_token_count() {
1219 let mut c = ctx();
1220 c.system.push(Message::system("x".repeat(400)), 100);
1221
1222 let rc = render(&c, 10, &engine(), 0);
1223
1224 let overflow = rc
1225 .budget_overflow
1226 .expect("fixed context overflow must be explicit");
1227 assert_eq!(overflow.kind, ContextBudgetOverflowKind::FixedContext);
1228 assert_eq!(overflow.required_tokens, 100);
1229 assert_eq!(overflow.max_tokens, 10);
1230 }
1231}