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