1use super::partitions::ContextPartitions;
2#[cfg(test)]
3use super::fault::stable_hash;
4use super::task_state::TaskState;
5use super::token_engine::ContextTokenEngine;
6use crate::mm::handle::{HandleTable, Residency};
7use crate::types::message::{Content, ContentPart, Message, Role};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RenderedContext {
28 pub system_text: String,
30 pub system_stable: String,
32 pub system_knowledge: String,
34 pub turns: Vec<Message>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub state_turn: Option<Message>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub frozen_prefix_len: Option<usize>,
48}
49
50#[cfg(test)]
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub(crate) struct PrefixFingerprint {
60 pub system_stable_hash: u64,
61 pub system_knowledge_hash: u64,
62 pub turn_hashes: Vec<u64>,
65}
66
67#[cfg(test)]
68impl PrefixFingerprint {
69 pub(crate) fn extends(&self, prev: &PrefixFingerprint) -> bool {
74 self.system_stable_hash == prev.system_stable_hash
75 && self.system_knowledge_hash == prev.system_knowledge_hash
76 && prev.turn_hashes.len() <= self.turn_hashes.len()
77 && self.turn_hashes[..prev.turn_hashes.len()] == prev.turn_hashes[..]
78 }
79
80 pub(crate) fn common_turn_prefix(&self, prev: &PrefixFingerprint) -> usize {
84 self.turn_hashes
85 .iter()
86 .zip(prev.turn_hashes.iter())
87 .take_while(|(a, b)| a == b)
88 .count()
89 }
90}
91
92#[cfg(test)]
97fn hash_turn(msg: &Message) -> u64 {
98 let material =
99 serde_json::to_vec(&(&msg.role, &msg.content, &msg.tool_calls)).unwrap_or_default();
100 stable_hash(&material)
101}
102
103#[cfg(test)]
104impl RenderedContext {
105 pub(crate) fn prefix_fingerprint(&self) -> PrefixFingerprint {
108 PrefixFingerprint {
109 system_stable_hash: stable_hash(self.system_stable.as_bytes()),
110 system_knowledge_hash: stable_hash(self.system_knowledge.as_bytes()),
111 turn_hashes: self.turns.iter().map(hash_turn).collect(),
112 }
113 }
114}
115
116fn build_system_stable(partitions: &ContextPartitions) -> String {
117 partitions.system.messages
118 .iter()
119 .filter_map(|m| m.content.as_text())
120 .collect::<Vec<_>>()
121 .join("\n\n")
122}
123
124fn build_system_knowledge(partitions: &ContextPartitions) -> String {
125 partitions.knowledge.messages()
126 .filter_map(|m| m.content.as_text())
127 .collect::<Vec<_>>()
128 .join("\n\n")
129}
130
131fn salience_footer(ts: &TaskState) -> Option<String> {
145 if ts.goal.is_empty() {
146 return None;
147 }
148 let mut clauses: Vec<String> = Vec::new();
149
150 let recent = ts.recent_actions.as_slice();
154 let action_name = |entry: &str| entry.split('(').next().unwrap_or(entry).to_string();
155 if let Some(last) = recent.last() {
156 let start = recent.len().saturating_sub(3);
157 let names = recent[start..].iter().map(|e| action_name(e)).collect::<Vec<_>>().join(" → ");
158 clauses.push(format!("did: {names}"));
159
160 let trailing_repeat = recent.iter().rev().take_while(|a| *a == last).count();
163 if trailing_repeat >= 2 {
164 clauses.push(format!(
165 "STOP: `{}` repeated {trailing_repeat}× unchanged — do something different or report",
166 action_name(last)
167 ));
168 }
169 }
170
171 let active_step = ts
173 .current_step
174 .and_then(|i| ts.plan.get(i).map(|s| (i, s)))
175 .filter(|(_, s)| !s.done);
176 if let Some((i, step)) = active_step {
177 clauses.push(format!("next: step {} — {}", i + 1, step.label));
178 } else if !recent.is_empty() {
179 clauses.push("next: advance the goal".to_string());
180 }
181
182 if let Some(d) = ts.directives.last() {
183 clauses.push(format!("must: {d}"));
184 }
185
186 let body = if clauses.is_empty() {
189 format!("→ focus: {}", ts.goal)
190 } else {
191 format!("→ {}", clauses.join(" · "))
192 };
193 Some(body)
194}
195
196fn build_state_turn(partitions: &ContextPartitions) -> Option<Message> {
200 let task = partitions.task_state.format_compact();
201 if task.is_empty() && partitions.signals.is_empty() {
202 return None;
203 }
204 let mut parts: Vec<String> = Vec::new();
205 if !task.is_empty() {
206 parts.push(task);
207 }
208 let signals_text = partitions.signals.join("\n");
209 if !signals_text.is_empty() {
210 parts.push(signals_text);
211 }
212 if let Some(footer) = salience_footer(&partitions.task_state) {
213 parts.push(footer);
214 }
215 let body = parts.join("\n\n");
216 Some(Message::user(format!("{body}\n\nProceed.")))
217}
218
219fn normalize_turn_prefix(turns: &mut Vec<Message>) {
222 if !turns.is_empty() && matches!(turns[0].role, Role::Assistant | Role::Tool) {
223 turns.insert(0, Message::user("[context resumed]"));
224 }
225}
226
227fn collapse_preview(output: &str) -> String {
232 const PREVIEW_BYTES: usize = 160;
233 let mut end = PREVIEW_BYTES.min(output.len());
234 while end > 0 && !output.is_char_boundary(end) {
235 end -= 1;
236 }
237 let dropped = output.len().saturating_sub(end);
238 format!(
239 "{}…\n[collapsed: {dropped} chars projected out of view; full result retained in history]",
240 &output[..end]
241 )
242}
243
244const NARRATION_STUB: &str = "[earlier narration collapsed; tool call(s) preserved below — current progress is in the TASK STATE block]";
247
248const NARRATION_COLLAPSE_MIN_CHARS: usize = 40;
251
252fn project_assistant_narration(msg: &Message, enabled: bool) -> Option<Message> {
260 if !enabled || msg.role != Role::Assistant || msg.tool_calls.is_empty() {
261 return None;
262 }
263 let Content::Text(text) = &msg.content else {
264 return None;
265 };
266 if text == NARRATION_STUB || text.chars().count() < NARRATION_COLLAPSE_MIN_CHARS {
267 return None;
268 }
269 let mut projected = msg.clone();
270 projected.content = Content::Text(NARRATION_STUB.to_string());
271 projected.token_count = None; Some(projected)
273}
274
275fn project_message(msg: &Message, handles: &HandleTable) -> Option<Message> {
278 let Content::Parts(parts) = &msg.content else {
279 return None;
280 };
281 let mut changed = false;
282 let new_parts: Vec<ContentPart> = parts
283 .iter()
284 .map(|part| match part {
285 ContentPart::ToolResult { call_id, output, is_error }
286 if matches!(
287 handles.residency_for_source(call_id),
288 Some(Residency::Collapsed)
289 ) =>
290 {
291 changed = true;
292 ContentPart::ToolResult {
293 call_id: call_id.clone(),
294 output: collapse_preview(output),
295 is_error: *is_error,
296 }
297 }
298 other => other.clone(),
299 })
300 .collect();
301 if changed {
302 let mut projected = msg.clone();
303 projected.content = Content::Parts(new_parts);
304 projected.token_count = None; Some(projected)
306 } else {
307 None
308 }
309}
310
311#[cfg(test)]
317pub(crate) fn render(
318 partitions: &ContextPartitions,
319 budget: u32,
320 engine: &ContextTokenEngine,
321 preserve_recent_msgs: usize,
322) -> RenderedContext {
323 render_projected(partitions, budget, engine, preserve_recent_msgs, &HandleTable::new(), 0, false)
326}
327
328pub fn render_projected(
337 partitions: &ContextPartitions,
338 budget: u32,
339 engine: &ContextTokenEngine,
340 preserve_recent_msgs: usize,
341 handles: &HandleTable,
342 frozen_history_len: usize,
343 collapse_narration: bool,
344) -> RenderedContext {
345 let system_stable = build_system_stable(partitions);
346 let system_knowledge = build_system_knowledge(partitions);
347 let system_text = [system_stable.as_str(), system_knowledge.as_str()]
348 .iter()
349 .filter(|s| !s.is_empty())
350 .cloned()
351 .collect::<Vec<_>>()
352 .join("\n\n");
353
354 let system_tokens = engine.count(&system_text).min(budget);
355 let mut remaining = budget.saturating_sub(system_tokens);
356
357 let mut kept_rev: Vec<Message> = Vec::new();
360 for msg in partitions.history.messages.iter().rev() {
361 let is_protected = kept_rev.len() < preserve_recent_msgs;
362 let projected = project_message(msg, handles).or_else(|| {
367 if is_protected { None } else { project_assistant_narration(msg, collapse_narration) }
368 });
369 let effective = projected.as_ref().unwrap_or(msg);
370 let tokens = match &projected {
371 Some(p) => engine.count_message(p),
372 None => msg.token_count.unwrap_or_else(|| engine.count_message(msg)),
373 };
374 if tokens == 0 { continue; }
375
376 if is_protected {
377 kept_rev.push(effective.clone());
378 remaining = remaining.saturating_sub(tokens);
379 continue;
380 }
381
382 if tokens <= remaining {
383 kept_rev.push(effective.clone());
384 remaining = remaining.saturating_sub(tokens);
385 } else if remaining > 0 {
386 match &effective.content {
387 Content::Text(_) => {}
393 Content::Parts(_) => kept_rev.push(effective.clone()),
395 }
396 break;
397 } else {
398 break;
399 }
400 }
401
402 kept_rev.reverse();
403 let mut turns = kept_rev;
404 normalize_turn_prefix(&mut turns);
405
406 let state_turn = build_state_turn(partitions);
410
411 let hot = partitions
418 .history
419 .messages
420 .len()
421 .saturating_sub(frozen_history_len);
422 let frozen_prefix_len = if frozen_history_len > 0 && hot > 0 && hot < turns.len() {
423 Some(turns.len() - hot)
424 } else {
425 None
426 };
427
428 RenderedContext { system_text, system_stable, system_knowledge, turns, state_turn, frozen_prefix_len }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::context::config::ContextConfig;
435 use crate::context::partitions::ContextPartitions;
436 use crate::context::task_state::{PlanStep, TaskState};
437 use crate::context::token_engine::ContextTokenEngine;
438 use crate::types::message::{Message, Role};
439
440 fn engine() -> ContextTokenEngine { ContextTokenEngine::char_approx() }
441 fn ctx() -> ContextPartitions { ContextPartitions::new(&ContextConfig::default()) }
442
443 #[test]
444 fn system_stable_contains_system_partition() {
445 let mut c = ctx();
446 c.system.push(Message::system("You are helpful."), 10);
447 let rc = render(&c, 10_000, &engine(), 4);
448 assert!(rc.system_stable.contains("You are helpful."));
449 assert!(rc.system_text.contains("You are helpful."));
450 }
451
452 #[test]
453 fn system_knowledge_contains_knowledge_partition() {
454 let mut c = ctx();
455 c.knowledge.push(Message::system("skill: debug"), 10);
456 let rc = render(&c, 10_000, &engine(), 4);
457 assert!(rc.system_knowledge.contains("skill: debug"));
458 assert!(rc.system_text.contains("skill: debug"));
459 }
460
461 #[test]
462 fn task_state_appears_in_state_turn() {
463 let mut c = ctx();
464 c.task_state = TaskState { goal: "find the bug".to_string(), ..Default::default() };
465 let rc = render(&c, 10_000, &engine(), 4);
466 assert!(!rc.system_text.contains("[TASK STATE]"), "task_state must not be in system_text");
467 let state = rc.state_turn.as_ref().expect("should have a state turn");
468 assert_eq!(state.role, Role::User);
469 assert!(state.content.as_text().unwrap().contains("[TASK STATE] goal: find the bug"));
470 assert!(!rc.turns.iter().any(|m| m.content.as_text().map(|t| t.contains("[TASK STATE]")).unwrap_or(false)));
472 }
473
474 #[test]
475 fn signals_appear_in_state_turn() {
476 let mut c = ctx();
477 c.task_state = TaskState { goal: "g".to_string(), ..Default::default() };
478 c.signals.push("[ROLLBACK] tool failed".to_string());
479 let rc = render(&c, 10_000, &engine(), 4);
480 let state = rc.state_turn.as_ref().unwrap();
481 assert!(state.content.as_text().unwrap().contains("[ROLLBACK] tool failed"));
482 }
483
484 #[test]
485 fn empty_task_state_no_state_turn() {
486 let c = ctx();
487 let rc = render(&c, 10_000, &engine(), 4);
488 assert!(rc.state_turn.is_none());
490 assert!(rc.turns.is_empty());
491 }
492
493 #[test]
494 fn history_excludes_state_turn() {
495 let mut c = ctx();
496 c.task_state = TaskState { goal: "g".to_string(), ..Default::default() };
497 c.history.push(Message::user("step 1"), 5);
498 c.history.push(Message::assistant("done"), 5);
499 let rc = render(&c, 10_000, &engine(), 4);
500 assert!(rc.state_turn.as_ref().unwrap().content.as_text().unwrap().contains("[TASK STATE]"));
502 assert_eq!(rc.turns[0].role, Role::User);
503 assert_eq!(rc.turns[0].content.as_text(), Some("step 1"));
504 assert_eq!(rc.turns[1].role, Role::Assistant);
505 }
506
507 #[test]
508 fn all_assistant_tool_history_gets_anchor_user_turn() {
509 let mut c = ctx();
510 c.history.push(Message::assistant("reply"), 5);
511 let rc = render(&c, 10_000, &engine(), 4);
512 assert_eq!(rc.turns[0].role, Role::User);
513 }
514
515 #[test]
516 fn zero_token_messages_skipped() {
517 let mut c = ctx();
518 c.history.push(Message::user("zero"), 0);
519 c.history.push(Message::user("real"), 5);
520 let rc = render(&c, 10_000, &engine(), 4);
521 assert!(rc.turns.iter().any(|m| m.content.as_text() == Some("real")));
523 assert!(!rc.turns.iter().any(|m| m.content.as_text() == Some("zero")));
524 }
525
526 #[test]
527 fn collapsed_tool_result_renders_as_preview_without_mutating_history() {
528 use crate::mm::handle::{Handle, HandleKind, HandleTable, Residency};
529
530 let mut c = ctx();
531 let long = "DATA ".repeat(200); c.history.push(
533 Message::tool(vec![ContentPart::ToolResult {
534 call_id: "c1".into(),
535 output: long.clone(),
536 is_error: false,
537 }]),
538 250,
539 );
540
541 let mut handles = HandleTable::new();
542 let mut h = Handle::resident_for(1, HandleKind::ToolResult, 250, "c1");
543 h.residency = Residency::Collapsed;
544 handles.insert(h);
545
546 let rc = render_projected(&c, 10_000, &engine(), 4, &handles, 0, false);
547 let rendered: String = rc
548 .turns
549 .iter()
550 .flat_map(|m| match &m.content {
551 Content::Parts(parts) => parts.clone(),
552 _ => Vec::new(),
553 })
554 .find_map(|p| match p {
555 ContentPart::ToolResult { output, .. } => Some(output),
556 _ => None,
557 })
558 .expect("tool result rendered");
559 assert!(rendered.contains("[collapsed:"));
561 assert!(rendered.len() < long.len());
562 let stored = match &c.history.messages[0].content {
563 Content::Parts(parts) => match &parts[0] {
564 ContentPart::ToolResult { output, .. } => output.clone(),
565 _ => unreachable!(),
566 },
567 _ => unreachable!(),
568 };
569 assert_eq!(stored, long, "projection must not mutate stored history");
570 }
571
572 #[test]
573 fn resident_tool_result_renders_in_full() {
574 use crate::mm::handle::{Handle, HandleKind, HandleTable};
575
576 let mut c = ctx();
577 let body = "RESIDENT BODY ".repeat(20);
578 c.history.push(
579 Message::tool(vec![ContentPart::ToolResult {
580 call_id: "c2".into(),
581 output: body.clone(),
582 is_error: false,
583 }]),
584 60,
585 );
586 let mut handles = HandleTable::new();
587 handles.insert(Handle::resident_for(1, HandleKind::ToolResult, 60, "c2"));
588
589 let rc = render_projected(&c, 10_000, &engine(), 4, &handles, 0, false);
590 let rendered: String = rc
591 .turns
592 .iter()
593 .flat_map(|m| match &m.content {
594 Content::Parts(parts) => parts.clone(),
595 _ => Vec::new(),
596 })
597 .find_map(|p| match p {
598 ContentPart::ToolResult { output, .. } => Some(output),
599 _ => None,
600 })
601 .expect("tool result rendered");
602 assert_eq!(rendered, body);
603 assert!(!rendered.contains("[collapsed:"));
604 }
605
606 #[test]
609 fn state_turn_footer_leads_with_next_step_not_bare_goal() {
610 let mut c = ctx();
611 c.task_state = TaskState {
612 goal: "ship the cache work".to_string(),
613 plan: vec![PlanStep { label: "do E".to_string(), done: false }],
614 current_step: Some(0),
615 ..Default::default()
616 };
617 c.task_state.record_directive("don't break ABI");
618 let rc = render(&c, 100_000, &engine(), 4);
619 let text = rc.state_turn.unwrap().content.as_text().unwrap().to_string();
620
621 assert!(text.starts_with("[TASK STATE] goal: ship the cache work"));
623 let before_proceed = text.rsplit_once("\n\nProceed.").expect("ends with Proceed").0;
625 let last_block = before_proceed.rsplit("\n\n").next().unwrap();
626 assert!(last_block.starts_with("→ next: step 1 — do E"), "got: {last_block}");
627 assert!(last_block.contains("must: don't break ABI"));
628 assert!(!last_block.contains("focus: ship the cache work"), "got: {last_block}");
630 }
631
632 #[test]
633 fn footer_falls_back_to_focus_goal_when_nothing_done_yet() {
634 let mut c = ctx();
636 c.task_state = TaskState { goal: "build the thing".to_string(), ..Default::default() };
637 let rc = render(&c, 100_000, &engine(), 4);
638 let text = rc.state_turn.unwrap().content.as_text().unwrap().to_string();
639 let footer = text.rsplit_once("\n\nProceed.").unwrap().0.rsplit("\n\n").next().unwrap();
640 assert_eq!(footer, "→ focus: build the thing");
641 }
642
643 #[test]
644 fn footer_shows_recent_actions_and_forward_nudge_without_a_plan() {
645 let mut c = ctx();
648 c.task_state = TaskState { goal: "rebuild §4.4 as SVG".to_string(), ..Default::default() };
649 c.task_state.note_actions("module_list");
650 c.task_state.note_actions("module_read");
651 let rc = render(&c, 100_000, &engine(), 4);
652 let footer = rc.state_turn.unwrap().content.as_text().unwrap()
653 .rsplit_once("\n\nProceed.").unwrap().0.rsplit("\n\n").next().unwrap().to_string();
654 assert!(footer.contains("did: module_list → module_read"), "got: {footer}");
655 assert!(footer.contains("next: advance the goal"), "got: {footer}");
656 assert!(!footer.contains("focus: rebuild §4.4 as SVG"), "goal must not lead the footer");
657 }
658
659 #[test]
660 fn footer_raises_stop_on_repeated_action() {
661 let mut c = ctx();
663 c.task_state = TaskState { goal: "g".to_string(), ..Default::default() };
664 c.task_state.note_actions("document_read");
665 c.task_state.note_actions("document_read");
666 c.task_state.note_actions("document_read");
667 let rc = render(&c, 100_000, &engine(), 4);
668 let footer = rc.state_turn.unwrap().content.as_text().unwrap()
669 .rsplit_once("\n\nProceed.").unwrap().0.rsplit("\n\n").next().unwrap().to_string();
670 assert!(footer.contains("STOP: `document_read` repeated 3×"), "got: {footer}");
671 }
672
673 #[test]
674 fn no_salience_footer_without_a_goal() {
675 let mut c = ctx();
676 c.signals.push("[ROLLBACK] tool failed".to_string());
677 let rc = render(&c, 100_000, &engine(), 4);
678 let text = rc.state_turn.unwrap().content.as_text().unwrap().to_string();
679 assert!(!text.contains("→ focus:"), "no goal ⇒ no footer");
680 assert!(text.contains("[ROLLBACK] tool failed"));
682 }
683
684 #[test]
687 fn prefix_fingerprint_is_stable_when_appending_history() {
688 let mut c = ctx();
689 c.system.push(Message::system("rules"), 5);
690 c.knowledge.push(Message::system("skill: debug"), 5);
691 c.history.push(Message::user("turn A"), 5);
692 c.history.push(Message::assistant("turn B"), 5);
693 let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
694
695 c.history.push(Message::user("turn C"), 5);
697 let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
698
699 assert!(fp2.extends(&fp1), "appending must only grow the tail, never drift the prefix");
700 assert_eq!(fp2.common_turn_prefix(&fp1), 2, "both prior turns stay cache-reusable");
701 assert_eq!(fp2.turn_hashes.len(), 3);
702 }
703
704 #[test]
705 fn prefix_fingerprint_ignores_state_turn() {
706 let mut c = ctx();
709 c.history.push(Message::user("turn A"), 5);
710 c.task_state = TaskState { goal: "first goal".to_string(), ..Default::default() };
711 let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
712
713 c.task_state = TaskState { goal: "totally different goal".to_string(), ..Default::default() };
714 c.signals.push("[ROLLBACK] whatever".to_string());
715 let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
716
717 assert_eq!(fp1, fp2, "volatile state must not perturb the cacheable prefix");
718 }
719
720 #[test]
721 fn prefix_fingerprint_detects_system_drift() {
722 let mut c = ctx();
723 c.system.push(Message::system("rules v1"), 5);
724 c.history.push(Message::user("turn A"), 5);
725 let fp1 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
726
727 c.system.messages.clear();
728 c.system.push(Message::system("rules v2"), 5);
729 let fp2 = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
730
731 assert_ne!(fp1.system_stable_hash, fp2.system_stable_hash);
732 assert!(!fp2.extends(&fp1), "a system-block edit invalidates the whole prefix");
733 }
734
735 #[test]
736 fn prefix_fingerprint_detects_in_place_collapse_churn() {
737 use crate::mm::handle::{Handle, HandleKind, HandleTable, Residency};
738
739 let mut c = ctx();
740 c.history.push(Message::user("start"), 5);
741 let long = "DATA ".repeat(200);
742 c.history.push(
743 Message::tool(vec![ContentPart::ToolResult {
744 call_id: "c1".into(),
745 output: long,
746 is_error: false,
747 }]),
748 250,
749 );
750 c.history.push(Message::user("recent"), 5);
751
752 let resident = render(&c, 100_000, &engine(), 4).prefix_fingerprint();
753
754 let mut handles = HandleTable::new();
757 let mut h = Handle::resident_for(1, HandleKind::ToolResult, 250, "c1");
758 h.residency = Residency::Collapsed;
759 handles.insert(h);
760 let collapsed = render_projected(&c, 100_000, &engine(), 4, &handles, 0, false).prefix_fingerprint();
761
762 assert_eq!(collapsed.common_turn_prefix(&resident), 1, "drift begins at the collapsed turn");
764 assert!(!collapsed.extends(&resident));
765 }
766
767 fn assistant_with_call(text: &str) -> Message {
770 let mut m = Message::assistant(text);
771 m.tool_calls = vec![crate::types::message::ToolCall {
772 id: "c1".into(),
773 name: "module_read".into(),
774 arguments: serde_json::json!({}),
775 }];
776 m
777 }
778
779 #[test]
780 fn old_assistant_narration_collapses_keeping_tool_calls() {
781 let mut c = ctx();
782 c.history.push(assistant_with_call(&"好的,我来将 §4.4 的 Mermaid 部署架构图重新构建为 SVG 版本。先找到当前 Mermaid 模块的位置。".repeat(1)), 60);
784 for i in 0..5 { c.history.push(Message::user(format!("recent {i}")), 5); }
785
786 let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
788 let narration = rc
789 .turns
790 .iter()
791 .find(|m| m.content.as_text() == Some(NARRATION_STUB))
792 .expect("old narration replaced by stub");
793 assert_eq!(narration.tool_calls.len(), 1, "tool call (pairing) preserved");
794 assert_eq!(narration.tool_calls[0].name, "module_read");
795 assert!(!rc.turns.iter().any(|m| m.content.as_text().map(|t| t.contains("先找到当前 Mermaid")).unwrap_or(false)));
797 assert!(c.history.messages[0].content.as_text().unwrap().contains("先找到当前 Mermaid"));
799
800 let rc_off = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, false);
802 assert!(rc_off.turns.iter().any(|m| m.content.as_text().map(|t| t.contains("先找到当前 Mermaid")).unwrap_or(false)));
803 }
804
805 #[test]
806 fn recent_assistant_narration_within_window_is_not_collapsed() {
807 let mut c = ctx();
808 c.history.push(assistant_with_call(&"好的,我来将 §4.4 重新构建为 SVG。先定位模块位置确认范围读取内容。".to_string()), 60);
810 c.history.push(Message::user("ok"), 5);
811 let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
812 assert!(rc.turns.iter().any(|m| m.content.as_text().map(|t| t.contains("先定位模块位置")).unwrap_or(false)), "recent narration kept verbatim");
813 }
814
815 #[test]
816 fn assistant_without_tool_calls_is_never_collapsed() {
817 let mut c = ctx();
818 c.history.push(Message::assistant("这是给用户的最终结论,包含实质内容,不应被折叠掉以免丢信息。"), 40);
820 for i in 0..5 { c.history.push(Message::user(format!("r{i}")), 5); }
821 let rc = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true);
822 assert!(rc.turns.iter().any(|m| m.content.as_text().map(|t| t.contains("最终结论")).unwrap_or(false)), "answer-only turns are not narration");
823 }
824
825 #[test]
826 fn collapsing_narration_drifts_only_that_turn_in_the_cache_prefix() {
827 let mut c = ctx();
830 c.history.push(Message::user("start"), 5);
831 c.history.push(assistant_with_call(&"好的,我来将 §4.4 重新构建为 SVG 版本。先找到 Mermaid 模块的确切位置再读取其内容。".to_string()), 60);
832 for i in 0..4 { c.history.push(Message::user(format!("recent {i}")), 5); }
833
834 let verbatim = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, false).prefix_fingerprint();
835 let collapsed = render_projected(&c, 100_000, &engine(), 4, &HandleTable::new(), 0, true).prefix_fingerprint();
836 assert_eq!(collapsed.common_turn_prefix(&verbatim), 1, "only the collapsed turn drifts");
838 assert!(!collapsed.extends(&verbatim));
839 }
840
841 #[test]
842 fn protected_recent_messages_kept_whole_over_budget() {
843 let mut c = ctx();
844 c.history.push(Message::user("first message"), 5);
845 c.history.push(Message::user("a".repeat(1000)), 250);
846 let rc = render(&c, 10, &engine(), 4);
848 assert!(rc.turns.iter().any(|m| {
849 m.content.as_text().map(|t| t.contains("first message")).unwrap_or(false)
850 }));
851 }
852
853 #[test]
854 fn oversized_text_boundary_is_dropped_whole_not_truncated() {
855 let mut c = ctx();
858 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");
862 assert_eq!(rc.turns[0].content.as_text(), Some("recent"));
863 assert!(
864 !rc.turns.iter().any(|m| m
865 .content
866 .as_text()
867 .map(|t| t.starts_with("aaaa"))
868 .unwrap_or(false)),
869 "no truncated body in the prefix"
870 );
871 }
872}