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