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