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