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