1use serde_json::Value;
54use std::future::Future;
55use std::pin::Pin;
56use std::sync::OnceLock;
57
58use crate::prune::{prune, PruneConfig};
59
60use super::trim::{estimate_tokens, estimate_value_tokens, repair_orphaned_tool_calls};
61use crate::tokens::TokenEstimation;
62
63pub type SummarizeFuture = Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send>>;
65
66pub type SummarizeFn = dyn Fn(String) -> SummarizeFuture + Send + Sync;
71
72pub type Summarizer = Box<SummarizeFn>;
74
75pub const SUMMARY_PREFIX: &str = "[CONTEXT COMPACTION — REFERENCE ONLY]";
78
79pub const SUMMARY_END_MARKER: &str = "--- END OF CONTEXT SUMMARY ---";
81
82pub const CONTINUATION_PREFIX: &str = "[POST-COMPACTION — CONTINUE]";
90
91pub(crate) fn is_compaction_message(m: &Value) -> bool {
103 m["content"].as_str().is_some_and(|c| {
104 c.starts_with(SUMMARY_PREFIX)
105 || c.starts_with(CONTINUATION_PREFIX)
106 || c.starts_with(LOOP_GUIDANCE_PREFIX)
107 })
108}
109
110pub(crate) fn is_continuation_message(m: &Value) -> bool {
114 m["content"]
115 .as_str()
116 .is_some_and(|c| c.starts_with(CONTINUATION_PREFIX))
117}
118
119pub const LOOP_GUIDANCE_PREFIX: &str = "[loop-guidance]";
127
128const META_NARRATION_CUES: &[&str] = &[
135 "keep describing",
136 "describing what i",
137 "never call tools",
138 "never actually call",
139 "did not call any tool",
140 "without calling a tool",
141 "stop describing and start acting",
142];
143
144fn is_meta_narration(content: &str) -> bool {
147 let lc = content.to_lowercase();
148 META_NARRATION_CUES.iter().any(|cue| lc.contains(cue))
149}
150
151pub(crate) fn is_compaction_text(content: &str) -> bool {
155 content.starts_with(SUMMARY_PREFIX)
156}
157
158const TAIL_MIN_MESSAGES: usize = 3;
161
162const SUMMARY_INPUT_MSG_CAP: usize = 2_000;
164
165const THRASH_MIN_SAVINGS: f32 = 0.10;
168const GAP_MIN_PROGRESS: f32 = 0.25;
172const ABS_MIN_RECLAIM_TOKENS: usize = 200;
174
175#[derive(Debug)]
184pub struct CompressState {
185 last_savings: [f32; 2],
187 last_effective: [bool; 2],
191 attempts: usize,
192 disabled: bool,
193 notified: bool,
194 failopen_notified: bool,
198}
199
200impl Default for CompressState {
201 fn default() -> Self {
202 Self::new()
203 }
204}
205
206impl CompressState {
207 pub fn new() -> Self {
208 Self {
209 last_savings: [1.0, 1.0],
210 last_effective: [true, true],
211 attempts: 0,
212 disabled: false,
213 notified: false,
214 failopen_notified: false,
215 }
216 }
217
218 fn record(&mut self, tokens_before: usize, tokens_after: usize, budget: usize) {
229 let relative = if tokens_before > 0 {
230 1.0 - (tokens_after as f32 / tokens_before as f32)
231 } else {
232 0.0
233 };
234 let gap_before = tokens_before.saturating_sub(budget);
235 let gap_after = tokens_after.saturating_sub(budget);
236 let effective = tokens_after <= budget
237 || (gap_before > 0
238 && (gap_after as f32) <= (gap_before as f32) * (1.0 - GAP_MIN_PROGRESS))
239 || tokens_before.saturating_sub(tokens_after) >= ABS_MIN_RECLAIM_TOKENS
240 || relative >= THRASH_MIN_SAVINGS;
241 self.last_savings = [self.last_savings[1], relative];
242 self.last_effective = [self.last_effective[1], effective];
243 self.attempts += 1;
244 if self.attempts >= 2 && !self.last_effective[0] && !self.last_effective[1] {
245 self.disabled = true;
246 }
247 }
248
249 pub fn is_disabled(&self) -> bool {
253 self.disabled
254 }
255
256 pub fn reset(&mut self) {
260 *self = Self::new();
261 }
262
263 #[doc(hidden)]
266 pub fn latch_disabled_for_tests(&mut self) {
267 self.disabled = true;
268 self.notified = true;
269 }
270
271 pub fn counters(&self) -> CompressCounters {
275 let strikes = if self.attempts == 0 || self.last_effective[1] {
280 0
281 } else if self.attempts >= 2 && !self.last_effective[0] {
282 2
283 } else {
284 1
285 };
286 CompressCounters {
287 compressions: self.attempts,
288 strikes,
289 disabled: self.disabled,
290 last_reclaim: (self.attempts > 0).then_some(self.last_savings[1]),
291 }
292 }
293
294 fn take_notice(&mut self) -> Option<String> {
297 if self.disabled && !self.notified {
298 self.notified = true;
299 Some(
300 "context compression was ineffective twice in a row — auto-compression \
301 is disabled for this session; start a new conversation to reset"
302 .to_string(),
303 )
304 } else {
305 None
306 }
307 }
308
309 fn take_failopen_notice(&mut self) -> Option<String> {
316 if !self.failopen_notified {
317 self.failopen_notified = true;
318 Some(
319 "context exceeds the proven-good budget, but no authoritative window \
320 limit is known for this model — dispatching over budget and letting \
321 the backend decide; an accepted size raises the learned budget"
322 .to_string(),
323 )
324 } else {
325 None
326 }
327 }
328}
329
330#[derive(Debug, Clone, Copy, PartialEq)]
334pub struct CompressCounters {
335 pub compressions: usize,
338 pub strikes: usize,
341 pub disabled: bool,
344 pub last_reclaim: Option<f32>,
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub(crate) struct CompressTrigger {
356 pub budget: usize,
358 pub max_messages: Option<usize>,
361 pub hard_budget: bool,
365}
366
367pub(crate) fn compression_trigger(
382 len: usize,
383 current_tokens: usize,
384 message_tokens: usize,
385 count_threshold: usize,
386 token_threshold: Option<usize>,
387 send_budget: Option<usize>,
388 tool_tokens: usize,
389) -> Option<CompressTrigger> {
390 let token_threshold = token_threshold.filter(|&b| b > 0);
394 let send_budget = send_budget.filter(|&b| b > 0);
395
396 let count_fired = len > count_threshold;
397 let token_fired = token_threshold.is_some_and(|b| current_tokens > b);
398 let guard_fired = send_budget.is_some_and(|b| current_tokens > b);
399 if !(count_fired || token_fired || guard_fired) {
400 return None;
401 }
402 let mut budget = usize::MAX;
403 if token_fired {
404 budget = budget.min(token_threshold.unwrap_or(usize::MAX));
405 }
406 if guard_fired {
407 budget = budget.min(
408 send_budget
409 .unwrap_or(usize::MAX)
410 .saturating_sub(tool_tokens),
411 );
412 }
413 let hard_budget = budget != usize::MAX;
414 if !hard_budget {
415 budget = message_tokens / 2;
421 }
422 Some(CompressTrigger {
423 budget,
424 max_messages: count_fired.then_some(count_threshold / 2),
425 hard_budget,
426 })
427}
428
429pub(crate) struct CompressRequest<'a> {
435 pub messages: &'a [Value],
436 pub budget: usize,
439 pub max_messages: Option<usize>,
442 pub task: &'a str,
444 pub authoritative: bool,
452 pub hard_budget: bool,
459 pub focus: Option<&'a str>,
465 pub est: crate::tokens::TokenEstimation,
468 pub summary_input_cap_floor_chars: usize,
472 pub compaction_store: Option<&'a dyn crate::agentic::spill::SpillStore>,
477}
478
479impl<'a> CompressRequest<'a> {
480 pub(crate) fn user_initiated(
490 messages: &'a [Value],
491 task: &'a str,
492 focus: Option<&'a str>,
493 est: TokenEstimation,
494 summary_input_cap_floor_chars: usize,
495 ) -> Self {
496 Self {
497 messages,
498 budget: estimate_tokens(messages, est) / 2,
499 max_messages: None,
500 task,
501 hard_budget: false,
502 authoritative: true,
505 focus,
506 est,
507 summary_input_cap_floor_chars,
508 compaction_store: None,
511 }
512 }
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
517pub(crate) enum CompressAction {
518 Fit,
520 Pruned,
522 Summarized,
524 StaticFallback,
527 Refused,
531 DispatchedOverBudget,
538}
539
540impl CompressAction {
541 pub(crate) fn describe(self) -> &'static str {
543 match self {
544 Self::Fit => "no change",
545 Self::Pruned => "structural prune",
546 Self::Summarized => "prune + summary",
547 Self::StaticFallback => "prune + static marker",
548 Self::Refused => "refused",
549 Self::DispatchedOverBudget => "over budget — dispatched",
550 }
551 }
552}
553
554pub(crate) struct CompressOutcome {
556 pub messages: Vec<Value>,
557 pub action: CompressAction,
558 pub fired: bool,
560 pub tokens_before: usize,
561 pub tokens_after: usize,
562 pub notice: Option<String>,
564}
565
566pub(crate) async fn compress(
571 req: CompressRequest<'_>,
572 summarizer: Option<&SummarizeFn>,
573 state: &mut CompressState,
574) -> CompressOutcome {
575 let tokens_before = estimate_tokens(req.messages, req.est);
576 let tokens_over_entry = req.hard_budget && tokens_before > req.budget;
581 let over = |tokens: usize, len: usize| {
582 tokens > req.budget || req.max_messages.is_some_and(|m| len > m)
583 };
584
585 if !over(tokens_before, req.messages.len()) {
586 return CompressOutcome {
587 messages: req.messages.to_vec(),
588 action: CompressAction::Fit,
589 fired: false,
590 tokens_before,
591 tokens_after: tokens_before,
592 notice: None,
593 };
594 }
595 let mut force_marker = false;
599 if state.disabled && req.hard_budget {
600 if tokens_over_entry {
601 if req.authoritative {
602 force_marker = true;
609 } else {
610 return CompressOutcome {
617 messages: req.messages.to_vec(),
618 action: CompressAction::DispatchedOverBudget,
619 fired: false,
620 tokens_before,
621 tokens_after: tokens_before,
622 notice: state.take_failopen_notice(),
623 };
624 }
625 } else {
626 return CompressOutcome {
630 messages: req.messages.to_vec(),
631 action: CompressAction::Fit,
632 fired: false,
633 tokens_before,
634 tokens_after: tokens_before,
635 notice: state.take_notice(),
636 };
637 }
638 }
639
640 let pruned = prune(req.messages, &PruneConfig::default());
642 let prune_changed = pruned.chars_reclaimed > 0;
643 let pruned = pruned.messages;
644 let after_prune = estimate_tokens(&pruned, req.est);
645 if !over(after_prune, pruned.len()) {
646 if tokens_over_entry {
647 state.record(tokens_before, after_prune, req.budget);
648 }
649 return CompressOutcome {
650 messages: pruned,
651 action: CompressAction::Pruned,
652 fired: prune_changed,
653 tokens_before,
654 tokens_after: after_prune,
655 notice: state.take_notice(),
656 };
657 }
658
659 let boundary = compute_boundary(&pruned, req.budget, req.max_messages, req.est);
662 let middle = &pruned[boundary.head..boundary.tail_start];
663
664 let (mut assembled, mut action) = if middle.is_empty() {
665 (pruned.clone(), CompressAction::Pruned)
667 } else {
668 let body = if force_marker {
672 None
673 } else {
674 match summarizer {
675 Some(f) => {
676 let middle_cap = req
687 .est
688 .chars_for_tokens(req.budget)
689 .max(req.summary_input_cap_floor_chars);
690 summarize_middle(f, req.task, middle, middle_cap, req.focus).await
691 }
692 None => None,
693 }
694 };
695 let action = if body.is_some() {
696 CompressAction::Summarized
697 } else {
698 CompressAction::StaticFallback
699 };
700 let mut body = body.unwrap_or_else(|| static_fallback_text(middle.len()));
701 if let Some(crumb) = reread_breadcrumb(middle) {
707 body.push_str("\n\n");
708 body.push_str(&crumb);
709 }
710 if let Some(store) = req.compaction_store {
718 let verbatim: String = middle
719 .iter()
720 .map(render_message_raw)
721 .collect::<Vec<_>>()
722 .join("\n");
723 let id = store.store(redact_secrets(&verbatim));
724 body.push_str(&format!(
725 "\n\n[the full verbatim text of this compacted span is retrievable with \
726 memory_fetch(\"compaction:{id}\") — use it to recover an exact detail \
727 this summary dropped, instead of guessing]"
728 ));
729 }
730 let mut out = Vec::with_capacity(boundary.head + 1 + (pruned.len() - boundary.tail_start));
732 out.extend_from_slice(&pruned[..boundary.head]);
733 out.push(summary_message(&body));
734 out.extend_from_slice(&pruned[boundary.tail_start..]);
735 (out, action)
736 };
737
738 repair_orphaned_tool_calls(&mut assembled);
740
741 if estimate_tokens(&assembled, req.est) > req.budget {
755 let aggressive = prune(
756 &assembled,
757 &PruneConfig {
758 keep_last: trailing_tool_group_len(&assembled).max(2),
759 ..PruneConfig::default()
760 },
761 );
762 if aggressive.chars_reclaimed > 0 {
763 assembled = aggressive.messages;
764 if action == CompressAction::Fit {
765 action = CompressAction::Pruned;
766 }
767 }
768 if req.hard_budget
778 && estimate_tokens(&assembled, req.est) > req.budget
779 && reclaim_within_trailing_group(&mut assembled, req.budget, req.est)
780 && action == CompressAction::Fit
781 {
782 action = CompressAction::Pruned;
783 }
784 }
785
786 if force_marker && estimate_tokens(&assembled, req.est) > req.budget {
791 return CompressOutcome {
792 messages: req.messages.to_vec(),
793 action: CompressAction::Refused,
794 fired: false,
795 tokens_before,
796 tokens_after: tokens_before,
797 notice: state.take_notice(),
798 };
799 }
800
801 let tokens_after = estimate_tokens(&assembled, req.est);
802 let fired =
803 prune_changed || assembled.len() != req.messages.len() || tokens_after != tokens_before;
804 if tokens_over_entry && !force_marker {
807 state.record(tokens_before, tokens_after, req.budget);
808 }
809 CompressOutcome {
810 messages: assembled,
811 action,
812 fired,
813 tokens_before,
814 tokens_after,
815 notice: state.take_notice(),
816 }
817}
818
819#[derive(Debug, Clone)]
827pub struct ManualCompressOutcome {
828 pub messages: Vec<Value>,
830 pub fired: bool,
833 pub messages_before: usize,
834 pub messages_after: usize,
835 pub tokens_before: usize,
837 pub tokens_after: usize,
838 pub how: &'static str,
843 pub notice: Option<String>,
845}
846
847pub async fn compress_user_initiated(
865 messages: &[Value],
866 focus: Option<&str>,
867 summarizer: Option<&SummarizeFn>,
868 state: &mut CompressState,
869 est: crate::tokens::TokenEstimation,
870 summary_input_cap_floor_chars: usize,
871) -> ManualCompressOutcome {
872 let task = messages
873 .iter()
874 .find(|m| m["role"].as_str() == Some("user") && !is_compaction_message(m))
875 .and_then(|m| m["content"].as_str())
876 .unwrap_or_default()
877 .to_string();
878 let outcome = compress(
879 CompressRequest::user_initiated(messages, &task, focus, est, summary_input_cap_floor_chars),
880 summarizer,
881 state,
882 )
883 .await;
884 if outcome.fired {
885 state.record(
887 outcome.tokens_before,
888 outcome.tokens_after,
889 outcome.tokens_before / 2,
890 );
891 }
892 let notice = outcome.notice.or_else(|| state.take_notice());
893 ManualCompressOutcome {
894 messages_before: messages.len(),
895 messages_after: outcome.messages.len(),
896 fired: outcome.fired,
897 tokens_before: outcome.tokens_before,
898 tokens_after: outcome.tokens_after,
899 how: outcome.action.describe(),
900 notice,
901 messages: outcome.messages,
902 }
903}
904
905struct Boundary {
910 head: usize,
913 tail_start: usize,
916}
917
918fn compute_boundary(
924 messages: &[Value],
925 budget: usize,
926 max_messages: Option<usize>,
927 est: TokenEstimation,
928) -> Boundary {
929 let head = head_len(messages);
930 let max_tail = max_messages.map(|m| m.saturating_sub(head + 1).max(1));
931
932 let tail_budget = (budget / 4).max(1);
935 let mut tail_start = messages.len();
936 let mut acc = 0usize;
937 let mut kept = 0usize;
938 while tail_start > head {
939 if max_tail.is_some_and(|m| kept >= m) {
940 break;
941 }
942 let t = estimate_value_tokens(&messages[tail_start - 1], est);
943 if kept >= TAIL_MIN_MESSAGES && acc + t > tail_budget {
944 break;
945 }
946 acc += t;
947 kept += 1;
948 tail_start -= 1;
949 }
950
951 if let Some(last_user) = messages
957 .iter()
958 .rposition(|m| m["role"].as_str() == Some("user") && !is_compaction_message(m))
959 {
960 if last_user >= head {
961 tail_start = tail_start.min(last_user);
962 }
963 }
964
965 while tail_start > head && messages[tail_start]["role"].as_str() == Some("tool") {
969 tail_start -= 1;
970 }
971
972 if let Some(max_tail) = max_tail {
982 let cap_start = messages.len().saturating_sub(max_tail);
983 if tail_start < cap_start {
984 tail_start = cap_start;
985 while tail_start > head && messages[tail_start]["role"].as_str() == Some("tool") {
986 tail_start -= 1;
987 }
988 }
989 }
990
991 Boundary { head, tail_start }
992}
993
994fn head_len(messages: &[Value]) -> usize {
999 let mut head = 0;
1000 while head < messages.len() && messages[head]["role"].as_str() == Some("system") {
1001 head += 1;
1002 }
1003 if head < messages.len()
1004 && messages[head]["role"].as_str() == Some("user")
1005 && !is_compaction_message(&messages[head])
1006 {
1007 head += 1;
1008 }
1009 head
1010}
1011
1012fn trailing_tool_group_len(messages: &[Value]) -> usize {
1032 messages
1033 .iter()
1034 .rposition(|m| m["tool_calls"].as_array().is_some_and(|t| !t.is_empty()))
1035 .map_or(0, |i| messages.len() - i)
1036}
1037
1038fn reclaim_within_trailing_group(
1053 assembled: &mut Vec<Value>,
1054 budget: usize,
1055 est: TokenEstimation,
1056) -> bool {
1057 let group_len = trailing_tool_group_len(assembled);
1058 if group_len == 0 {
1059 return false;
1060 }
1061 let group_start = assembled.len() - group_len;
1062 let outside = estimate_tokens(&assembled[..group_start], est);
1063 let group_tokens = estimate_tokens(&assembled[group_start..], est);
1064 if group_tokens <= budget.saturating_sub(outside) {
1065 return false;
1068 }
1069 let result_idxs: Vec<usize> = (group_start..assembled.len())
1071 .filter(|&i| assembled[i]["role"].as_str() == Some("tool"))
1072 .collect();
1073 let mut changed = false;
1074 for &i in result_idxs.iter().take(result_idxs.len().saturating_sub(1)) {
1075 let pass = prune(
1079 assembled,
1080 &PruneConfig {
1081 keep_last: assembled.len() - i - 1,
1082 ..PruneConfig::default()
1083 },
1084 );
1085 if pass.chars_reclaimed > 0 {
1086 *assembled = pass.messages;
1087 changed = true;
1088 }
1089 if estimate_tokens(assembled, est) <= budget {
1090 break;
1091 }
1092 }
1093 changed
1094}
1095
1096fn static_fallback_text(removed: usize) -> String {
1103 format!("Summary generation was unavailable. {removed} message(s) were removed.")
1104}
1105
1106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1111enum ConvShape {
1112 Coding,
1114 General,
1116}
1117
1118fn middle_shape(middle: &[Value]) -> ConvShape {
1125 let has_tools = middle
1126 .iter()
1127 .any(|m| m["tool_calls"].as_array().is_some_and(|t| !t.is_empty()));
1128 if has_tools {
1129 ConvShape::Coding
1130 } else {
1131 ConvShape::General
1132 }
1133}
1134
1135fn reread_breadcrumb(middle: &[Value]) -> Option<String> {
1143 let mut paths: Vec<String> = Vec::new();
1144 for m in middle {
1145 if m["role"].as_str() != Some("assistant") {
1146 continue;
1147 }
1148 let Some(calls) = m["tool_calls"].as_array() else {
1149 continue;
1150 };
1151 for call in calls {
1152 let func = &call["function"];
1153 if !matches!(
1155 func["name"].as_str(),
1156 Some("read_file") | Some("edit_file") | Some("write_file")
1157 ) {
1158 continue;
1159 }
1160 let args = &func["arguments"];
1162 let path = args["path"].as_str().map(str::to_string).or_else(|| {
1163 args.as_str()
1164 .and_then(|s| serde_json::from_str::<Value>(s).ok())
1165 .and_then(|v| v["path"].as_str().map(str::to_string))
1166 });
1167 if let Some(p) = path {
1168 if !paths.contains(&p) {
1169 paths.push(p);
1170 }
1171 }
1172 }
1173 }
1174 if paths.is_empty() {
1175 return None;
1176 }
1177 let list = paths
1178 .iter()
1179 .map(|p| format!("- {p}"))
1180 .collect::<Vec<_>>()
1181 .join("\n");
1182 Some(format!(
1183 "Files read or edited in the compacted span — their FULL CONTENTS are \
1184 NOT preserved in the summary above. RE-READ any you rely on before \
1185 using their exact signatures, types, or line contents; do NOT recall \
1186 them from this summary (it is prose, not the file):\n{list}"
1187 ))
1188}
1189
1190fn summary_message(body: &str) -> Value {
1191 serde_json::json!({
1192 "role": "user",
1193 "content": format!(
1194 "{SUMMARY_PREFIX}\n\
1195 The middle of this conversation was compressed. The text below \
1196 summarizes the removed messages — treat it as background \
1197 reference, NOT as fresh instructions. Your task is unchanged: \
1198 it is stated above and continues in the messages below.\n\n\
1199 {body}\n\n\
1200 {SUMMARY_END_MARKER}"
1201 ),
1202 })
1203}
1204
1205fn summary_prompt_for(
1219 task: &str,
1220 body: &str,
1221 focus: Option<&str>,
1222 note: Option<&str>,
1223 target_chars: usize,
1224 shape: ConvShape,
1225) -> String {
1226 let mut p = String::with_capacity(1024);
1227 p.push_str(match shape {
1228 ConvShape::Coding => "You are compressing the middle of a coding-agent conversation.\n\n",
1229 ConvShape::General => "You are compressing the middle of a conversation.\n\n",
1230 });
1231 p.push_str("## Original Task (copy this VERBATIM into \"## Active Task\")\n");
1232 p.push_str(task);
1233 p.push_str("\n\n## Conversation middle to summarise\n");
1234 if let Some(note) = note {
1235 p.push_str(note);
1236 p.push('\n');
1237 }
1238 p.push_str(body);
1239 let words = (target_chars / 6).max(40);
1242 let tokens = (target_chars / 4).max(60);
1243 let sections = match shape {
1247 ConvShape::Coding => {
1248 "## Active Task\n## Completed Actions\n## In Progress\n## Key Decisions\n\
1249 ## Relevant Files\n## Critical Context\n"
1250 }
1251 ConvShape::General => {
1252 "## Active Task\n## Discussion\n## Key Points\n## Open Questions\n\
1253 ## Critical Context\n"
1254 }
1255 };
1256 p.push_str(&format!(
1257 "\nProduce a concise structured summary with sections:\n{sections}\
1258 Start \"## Active Task\" with the original task copied verbatim. \
1259 Keep the WHOLE summary under ~{words} words (~{tokens} tokens); if it \
1260 cannot all fit, drop low-salience detail — NEVER the Active Task. \
1261 Preserve specifics (file names, error messages, decisions). \
1262 Do NOT include commentary about the assistant's own behavior or \
1263 process (e.g. \"kept describing instead of acting\") — record only \
1264 task state: what was done, what remains, and the concrete next \
1265 action. \
1266 NEVER include API keys, tokens, passwords, or other credentials — \
1267 write [REDACTED] instead.",
1268 ));
1269 if let Some(focus) = focus {
1270 let focus = redact_secrets(focus);
1271 let focus = focus.trim();
1272 if !focus.is_empty() {
1273 p.push_str(&format!(
1274 "\nThe user asked for this compression and wants emphasis on \
1275 a topic: emphasize anything about {focus} — give it the bulk \
1276 of the summary's detail while keeping every section above."
1277 ));
1278 }
1279 }
1280 p
1281}
1282
1283fn summary_request(
1284 task: &str,
1285 middle: &[Value],
1286 middle_cap_chars: usize,
1287 focus: Option<&str>,
1288 shape: ConvShape,
1289) -> String {
1290 let rendered: Vec<String> = middle.iter().map(render_message).collect();
1294 let mut start = rendered.len();
1295 let mut total = 0usize;
1296 while start > 0 {
1297 let len = rendered[start - 1].chars().count();
1298 if start < rendered.len() && total + len > middle_cap_chars {
1299 break;
1300 }
1301 total += len;
1302 start -= 1;
1303 }
1304 let note = (start > 0).then(|| {
1305 format!(
1306 "[{start} older message(s) omitted from this summary input to fit \
1307 the summarizer's window]"
1308 )
1309 });
1310 let body: String = rendered[start..].concat();
1311 summary_prompt_for(
1312 task,
1313 &body,
1314 focus,
1315 note.as_deref(),
1316 middle_cap_chars / 3,
1317 shape,
1318 )
1319}
1320
1321async fn summarize_middle(
1331 summarizer: &SummarizeFn,
1332 task: &str,
1333 middle: &[Value],
1334 cap_chars: usize,
1335 focus: Option<&str>,
1336) -> Option<String> {
1337 let shape = middle_shape(middle);
1339 let rendered: Vec<String> = middle.iter().map(render_message).collect();
1340 let total: usize = rendered.iter().map(|r| r.chars().count()).sum();
1341 if total <= cap_chars {
1342 let req = redact_secrets(&summary_request(task, middle, cap_chars, focus, shape));
1345 return run_summary(summarizer, req).await;
1346 }
1347 let chunks = chunk_strings(&rendered, cap_chars);
1348 let n = chunks.len();
1349 let mut partials = Vec::with_capacity(n);
1350 for (i, chunk) in chunks.iter().enumerate() {
1351 let note = format!("[part {}/{} of the conversation middle]", i + 1, n);
1352 let req = redact_secrets(&summary_prompt_for(
1353 task,
1354 chunk,
1355 focus,
1356 Some(¬e),
1357 cap_chars / 3,
1358 shape,
1359 ));
1360 if let Some(s) = run_summary(summarizer, req).await {
1361 partials.push(s);
1362 }
1363 }
1364 reduce_partials(summarizer, task, partials, cap_chars, focus, shape).await
1365}
1366
1367fn chunk_strings(parts: &[String], cap: usize) -> Vec<String> {
1371 let mut chunks = Vec::new();
1372 let mut cur = String::new();
1373 let mut cur_len = 0usize;
1374 for p in parts {
1375 let len = p.chars().count();
1376 if cur_len > 0 && cur_len + len > cap {
1377 chunks.push(std::mem::take(&mut cur));
1378 cur_len = 0;
1379 }
1380 cur.push_str(p);
1381 cur_len += len;
1382 }
1383 if !cur.is_empty() {
1384 chunks.push(cur);
1385 }
1386 chunks
1387}
1388
1389fn reduce_partials<'a>(
1393 summarizer: &'a SummarizeFn,
1394 task: &'a str,
1395 partials: Vec<String>,
1396 cap_chars: usize,
1397 focus: Option<&'a str>,
1398 shape: ConvShape,
1399) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send + 'a>> {
1400 Box::pin(async move {
1401 match partials.len() {
1402 0 => None,
1403 1 => partials.into_iter().next(),
1404 _ => {
1405 let joined_len: usize = partials.iter().map(|p| p.chars().count() + 2).sum();
1406 if joined_len <= cap_chars {
1407 let body = partials.join("\n\n");
1408 let note = format!(
1409 "[{} partial summaries of ONE conversation — consolidate into one]",
1410 partials.len()
1411 );
1412 let req = redact_secrets(&summary_prompt_for(
1413 task,
1414 &body,
1415 focus,
1416 Some(¬e),
1417 cap_chars / 3,
1418 shape,
1419 ));
1420 return run_summary(summarizer, req).await;
1421 }
1422 let groups = chunk_strings(&partials, cap_chars);
1423 if groups.len() >= partials.len() {
1424 return Some(partials.join("\n\n"));
1426 }
1427 let mut next = Vec::with_capacity(groups.len());
1428 for g in &groups {
1429 let req = redact_secrets(&summary_prompt_for(
1430 task,
1431 g,
1432 focus,
1433 Some("[partial summaries — consolidate]"),
1434 cap_chars / 3,
1435 shape,
1436 ));
1437 if let Some(s) = run_summary(summarizer, req).await {
1438 next.push(s);
1439 }
1440 }
1441 reduce_partials(summarizer, task, next, cap_chars, focus, shape).await
1442 }
1443 }
1444 })
1445}
1446
1447async fn run_summary(summarizer: &SummarizeFn, req: String) -> Option<String> {
1450 match summarizer(req).await {
1451 Ok(s) if !s.trim().is_empty() => Some(s),
1452 Ok(_) => None,
1453 Err(e) => {
1454 tracing::warn!(error = %e, "compression summarizer failed — static marker fallback");
1455 None
1456 }
1457 }
1458}
1459
1460fn render_message(m: &Value) -> String {
1467 render_message_with(m, true)
1468}
1469
1470fn render_message_raw(m: &Value) -> String {
1476 render_message_with(m, false)
1477}
1478
1479fn render_message_with(m: &Value, hygiene: bool) -> String {
1480 let role = m["role"].as_str().unwrap_or("unknown");
1481 let mut line = format!("[{role}]");
1482 let tool_calls = m["tool_calls"].as_array();
1483 if let Some(tcs) = tool_calls {
1484 for tc in tcs {
1485 let name = tc["function"]["name"].as_str().unwrap_or("tool");
1486 let args = tc["function"]["arguments"].to_string();
1487 line.push_str(" called ");
1488 line.push_str(name);
1489 line.push('(');
1490 line.push_str(&excerpt(&redact_secrets(&args), 200));
1491 line.push(')');
1492 }
1493 }
1494 if let Some(content) = m["content"].as_str() {
1495 let harness_meta = role == "user"
1501 && (content.starts_with(LOOP_GUIDANCE_PREFIX)
1502 || content.starts_with(CONTINUATION_PREFIX));
1503 let narration_echo =
1504 role == "assistant" && tool_calls.is_none() && is_meta_narration(content);
1505 if hygiene && (harness_meta || narration_echo) {
1506 line.push_str(" (loop process correction omitted — not task state)");
1507 line.push('\n');
1508 return line;
1509 }
1510 if !content.is_empty() {
1511 line.push(' ');
1512 line.push_str(&excerpt(&redact_secrets(content), SUMMARY_INPUT_MSG_CAP));
1513 }
1514 }
1515 line.push('\n');
1516 line
1517}
1518
1519fn excerpt(s: &str, max_chars: usize) -> String {
1521 if s.chars().count() <= max_chars {
1522 s.to_string()
1523 } else {
1524 let head: String = s.chars().take(max_chars).collect();
1525 format!("{head}…")
1526 }
1527}
1528
1529const REDACTION_TABLE: &[(&str, &str)] = &[
1537 (
1539 r"(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\z)",
1540 "[REDACTED]",
1541 ),
1542 (r"\bsk-[A-Za-z0-9_-]{20,}", "[REDACTED]"),
1544 (r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}", "[REDACTED]"),
1546 (r"\bgithub_pat_[A-Za-z0-9_]{20,}", "[REDACTED]"),
1547 (r"\bAKIA[0-9A-Z]{16}\b", "[REDACTED]"),
1549 (
1551 r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}",
1552 "[REDACTED]",
1553 ),
1554 (
1557 r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{20,}",
1558 "Bearer [REDACTED]",
1559 ),
1560 (
1566 r#"(?i)\b(api[_-]?key|secret[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|password|passwd)\b["']?\s*[:=]\s*["']?[^\s"']{8,}["']?"#,
1567 "${1}=[REDACTED]",
1568 ),
1569];
1570
1571fn redaction_patterns() -> &'static Vec<(regex::Regex, &'static str)> {
1572 static PATTERNS: OnceLock<Vec<(regex::Regex, &'static str)>> = OnceLock::new();
1573 PATTERNS.get_or_init(|| {
1574 REDACTION_TABLE
1575 .iter()
1576 .map(|(pat, rep)| {
1577 (
1578 regex::Regex::new(pat).expect("redaction pattern must compile"),
1579 *rep,
1580 )
1581 })
1582 .collect()
1583 })
1584}
1585
1586pub(crate) fn redact_secrets(input: &str) -> String {
1590 let mut out = input.to_string();
1591 for (re, rep) in redaction_patterns() {
1592 if re.is_match(&out) {
1593 out = re.replace_all(&out, *rep).into_owned();
1594 }
1595 }
1596 out
1597}
1598
1599#[cfg(test)]
1604mod tests {
1605 use super::*;
1606
1607 const EST: TokenEstimation = TokenEstimation { chars_per_token: 4 };
1609 use serde_json::json;
1610 use std::sync::atomic::{AtomicUsize, Ordering};
1611 use std::sync::{Arc, Mutex};
1612
1613 fn sys(text: &str) -> Value {
1616 json!({"role": "system", "content": text})
1617 }
1618
1619 fn user(text: &str) -> Value {
1620 json!({"role": "user", "content": text})
1621 }
1622
1623 fn assistant_call(name: &str, args: Value) -> Value {
1624 json!({"role": "assistant", "content": "",
1625 "tool_calls": [{"function": {"name": name, "arguments": args}}]})
1626 }
1627
1628 fn tool_result(content: &str) -> Value {
1629 json!({"role": "tool", "content": content})
1630 }
1631
1632 fn tool_heavy(task: &str, rounds: usize, result_chars: usize) -> Vec<Value> {
1634 let mut msgs = vec![sys("you are newt"), user(task)];
1635 for i in 0..rounds {
1636 msgs.push(assistant_call(
1637 "read_file",
1638 json!({"path": format!("src/file_{i}.rs")}),
1639 ));
1640 msgs.push(tool_result(&format!("{i}:{}", "x".repeat(result_chars))));
1641 }
1642 msgs
1643 }
1644
1645 fn recording_summarizer(prompts: Arc<Mutex<Vec<String>>>, reply: &'static str) -> Summarizer {
1648 Box::new(move |prompt: String| {
1649 let prompts = prompts.clone();
1650 Box::pin(async move {
1651 prompts.lock().unwrap().push(prompt);
1652 Ok(reply.to_string())
1653 })
1654 })
1655 }
1656
1657 fn failing_summarizer(calls: Arc<AtomicUsize>) -> Summarizer {
1658 Box::new(move |_prompt: String| {
1659 let calls = calls.clone();
1660 Box::pin(async move {
1661 calls.fetch_add(1, Ordering::SeqCst);
1662 anyhow::bail!("summarizer endpoint 500")
1663 })
1664 })
1665 }
1666
1667 async fn run(
1670 messages: &[Value],
1671 budget: usize,
1672 max_messages: Option<usize>,
1673 summarizer: Option<&SummarizeFn>,
1674 state: &mut CompressState,
1675 ) -> CompressOutcome {
1676 compress(
1677 CompressRequest {
1678 messages,
1679 budget,
1680 max_messages,
1681 task: "fix the failing test",
1682 hard_budget: true,
1683 authoritative: true,
1684 focus: None,
1685 est: EST,
1686 summary_input_cap_floor_chars: 8_192,
1687 compaction_store: None,
1688 },
1689 summarizer,
1690 state,
1691 )
1692 .await
1693 }
1694
1695 async fn run_non_authoritative(
1699 messages: &[Value],
1700 budget: usize,
1701 max_messages: Option<usize>,
1702 summarizer: Option<&SummarizeFn>,
1703 state: &mut CompressState,
1704 ) -> CompressOutcome {
1705 compress(
1706 CompressRequest {
1707 messages,
1708 budget,
1709 max_messages,
1710 task: "fix the failing test",
1711 hard_budget: true,
1712 authoritative: false,
1713 focus: None,
1714 est: EST,
1715 summary_input_cap_floor_chars: 8_192,
1716 compaction_store: None,
1717 },
1718 summarizer,
1719 state,
1720 )
1721 .await
1722 }
1723
1724 async fn run_count_only(
1727 messages: &[Value],
1728 budget: usize,
1729 max_messages: Option<usize>,
1730 summarizer: Option<&SummarizeFn>,
1731 state: &mut CompressState,
1732 ) -> CompressOutcome {
1733 compress(
1734 CompressRequest {
1735 messages,
1736 budget,
1737 max_messages,
1738 task: "fix the failing test",
1739 hard_budget: false,
1740 authoritative: false,
1741 focus: None,
1742 est: EST,
1743 summary_input_cap_floor_chars: 8_192,
1744 compaction_store: None,
1745 },
1746 summarizer,
1747 state,
1748 )
1749 .await
1750 }
1751
1752 #[tokio::test]
1756 async fn within_budget_is_a_noop() {
1757 let msgs = tool_heavy("task", 2, 100);
1758 let mut state = CompressState::new();
1759 let out = run(&msgs, 100_000, None, None, &mut state).await;
1760 assert_eq!(out.action, CompressAction::Fit);
1761 assert!(!out.fired);
1762 assert_eq!(out.messages, msgs);
1763 assert_eq!(state.attempts, 0, "a no-op never counts as a compression");
1764 }
1765
1766 #[tokio::test]
1769 async fn prune_short_circuits_when_sufficient() {
1770 let big = "y".repeat(8_000);
1773 let mut msgs = vec![
1774 sys("you are newt"),
1775 user("task"),
1776 assistant_call("run_command", json!({"command": "cargo test"})),
1777 tool_result(&big),
1778 assistant_call("run_command", json!({"command": "cargo test"})),
1779 tool_result(&big),
1780 ];
1781 for i in 0..10 {
1782 msgs.push(user(&format!("filler {i}")));
1783 }
1784 let before = estimate_tokens(&msgs, EST);
1785 let budget = before - 1_000; let prompts = Arc::new(Mutex::new(Vec::new()));
1787 let s = recording_summarizer(prompts.clone(), "SUMMARY");
1788 let mut state = CompressState::new();
1789 let out = run(&msgs, budget, None, Some(&*s), &mut state).await;
1790 assert_eq!(out.action, CompressAction::Pruned);
1791 assert!(out.fired);
1792 assert!(out.tokens_after <= budget);
1793 assert_eq!(out.messages.len(), msgs.len(), "prune never drops messages");
1794 assert!(
1795 prompts.lock().unwrap().is_empty(),
1796 "summarizer must not be called when pruning suffices"
1797 );
1798 }
1799
1800 #[tokio::test]
1803 async fn summarizes_middle_with_markers_when_prune_insufficient() {
1804 let msgs = tool_heavy("ACTIVE TASK GAUNTLET-7f3d9c: do the thing", 6, 4_000);
1805 let before = estimate_tokens(&msgs, EST);
1806 let prompts = Arc::new(Mutex::new(Vec::new()));
1807 let s = recording_summarizer(prompts.clone(), "## Active Task\nGAUNTLET summary");
1808 let mut state = CompressState::new();
1809 let out = run(&msgs, before / 3, None, Some(&*s), &mut state).await;
1810
1811 assert_eq!(out.action, CompressAction::Summarized);
1812 assert!(out.fired);
1813 assert!(out.tokens_after < before);
1814 assert_eq!(out.messages[0], msgs[0]);
1816 assert_eq!(out.messages[1], msgs[1]);
1817 let summary = out.messages[2]["content"].as_str().unwrap();
1819 assert!(summary.starts_with(SUMMARY_PREFIX), "{summary}");
1820 assert!(summary.contains("GAUNTLET summary"), "{summary}");
1821 assert!(summary.contains(SUMMARY_END_MARKER), "{summary}");
1822 assert!(
1824 !out.messages.iter().any(|m| m["content"]
1825 .as_str()
1826 .is_some_and(|c| c.contains("earlier tool-call messages omitted"))),
1827 "the old placeholder-discard line must not appear"
1828 );
1829 }
1830
1831 #[tokio::test]
1839 async fn summarized_file_reads_get_a_reread_breadcrumb() {
1840 let sig = "pub fn connect(&self, url: &str, timeout: Duration) -> Result<Session, ConnErr>";
1841 let api_body = format!(
1842 "pub struct ApiClient;\nimpl ApiClient {{\n {sig} {{ todo!() }}\n}}\n{}",
1843 "// detail line\n".repeat(200)
1844 );
1845 let mut msgs = vec![
1846 sys("you are newt, a coding agent"),
1847 user("ACTIVE TASK: implement reconnect() on ApiClient using its connect() method"),
1848 assistant_call("read_file", json!({ "path": "src/api.rs" })),
1849 tool_result(&api_body), ];
1851 for i in 0..8 {
1854 msgs.push(assistant_call(
1855 "read_file",
1856 json!({ "path": format!("src/other_{i}.rs") }),
1857 ));
1858 msgs.push(tool_result(&format!(
1859 "// other file {i}\n{}",
1860 "filler line\n".repeat(150)
1861 )));
1862 }
1863 let before = estimate_tokens(&msgs, EST);
1864 let prompts = Arc::new(Mutex::new(Vec::new()));
1865 let s = recording_summarizer(
1867 prompts.clone(),
1868 "## Active Task\nImplement reconnect(). The agent earlier read src/api.rs \
1869 (defines ApiClient) and several other files.",
1870 );
1871 let mut state = CompressState::new();
1872 let out = run(&msgs, before / 2, None, Some(&*s), &mut state).await;
1873
1874 let assembled: String = out
1875 .messages
1876 .iter()
1877 .filter_map(|m| m["content"].as_str())
1878 .collect::<Vec<_>>()
1879 .join("\n");
1880 eprintln!(
1881 "#319: fired={} action={:?}\n{}",
1882 out.fired,
1883 out.action,
1884 &assembled[..assembled.len().min(1200)]
1885 );
1886 assert!(out.fired && out.action == CompressAction::Summarized);
1888 assert!(
1891 assembled.contains("src/api.rs"),
1892 "the dropped file must be named so the model knows to re-read it"
1893 );
1894 assert!(
1895 assembled.contains("RE-READ") && assembled.contains("do NOT recall"),
1896 "the breadcrumb must carry the re-read / don't-recall directive"
1897 );
1898 }
1899
1900 #[tokio::test]
1903 async fn summary_request_carries_task_verbatim_and_template() {
1904 let task = "ACTIVE TASK GAUNTLET-7f3d9c: read ten files then report";
1905 let mut msgs = tool_heavy(task, 6, 4_000);
1906 msgs[1] = user(task);
1907 let before = estimate_tokens(&msgs, EST);
1908 let prompts = Arc::new(Mutex::new(Vec::new()));
1909 let s = recording_summarizer(prompts.clone(), "SUMMARY");
1910 let mut state = CompressState::new();
1911 let out = compress(
1912 CompressRequest {
1913 messages: &msgs,
1914 budget: before / 3,
1915 max_messages: None,
1916 task,
1917 hard_budget: true,
1918 authoritative: true,
1919 focus: None,
1920 est: EST,
1921 summary_input_cap_floor_chars: 8_192,
1922 compaction_store: None,
1923 },
1924 Some(&*s),
1925 &mut state,
1926 )
1927 .await;
1928 assert_eq!(out.action, CompressAction::Summarized);
1929
1930 let prompts = prompts.lock().unwrap();
1931 assert_eq!(prompts.len(), 1);
1932 let p = &prompts[0];
1933 assert!(p.contains(task), "original task must appear verbatim: {p}");
1934 for section in [
1935 "## Active Task",
1936 "## Completed Actions",
1937 "## In Progress",
1938 "## Key Decisions",
1939 "## Relevant Files",
1940 "## Critical Context",
1941 ] {
1942 assert!(p.contains(section), "missing template section {section}");
1943 }
1944 assert!(p.contains("copied verbatim"), "verbatim-Active-Task rule");
1945 assert!(p.contains("[REDACTED]"), "redaction preamble present");
1946 }
1947
1948 #[tokio::test]
1950 async fn no_summarizer_uses_static_fallback_marker() {
1951 let msgs = tool_heavy("task", 6, 4_000);
1952 let before = estimate_tokens(&msgs, EST);
1953 let mut state = CompressState::new();
1954 let out = run(&msgs, before / 3, None, None, &mut state).await;
1955 assert_eq!(out.action, CompressAction::StaticFallback);
1956 let summary = out.messages[2]["content"].as_str().unwrap();
1957 assert!(summary.starts_with(SUMMARY_PREFIX), "{summary}");
1958 assert!(summary.contains(SUMMARY_END_MARKER), "{summary}");
1959 let removed = msgs.len() - (out.messages.len() - 1);
1962 assert!(
1963 summary.contains(&format!(
1964 "Summary generation was unavailable. {removed} message(s) were removed."
1965 )),
1966 "{summary}"
1967 );
1968 }
1969
1970 #[tokio::test]
1972 async fn summarizer_failure_falls_back_to_static_marker() {
1973 let msgs = tool_heavy("task", 6, 4_000);
1974 let before = estimate_tokens(&msgs, EST);
1975 let calls = Arc::new(AtomicUsize::new(0));
1976 let s = failing_summarizer(calls.clone());
1977 let mut state = CompressState::new();
1978 let out = run(&msgs, before / 3, None, Some(&*s), &mut state).await;
1979 assert_eq!(calls.load(Ordering::SeqCst), 1, "summarizer was attempted");
1980 assert_eq!(out.action, CompressAction::StaticFallback);
1981 let summary = out.messages[2]["content"].as_str().unwrap();
1982 assert!(summary.contains("Summary generation was unavailable."));
1983 }
1984
1985 #[tokio::test]
1987 async fn empty_summary_falls_back_to_static_marker() {
1988 let msgs = tool_heavy("task", 6, 4_000);
1989 let before = estimate_tokens(&msgs, EST);
1990 let prompts = Arc::new(Mutex::new(Vec::new()));
1991 let s = recording_summarizer(prompts.clone(), " \n ");
1992 let mut state = CompressState::new();
1993 let out = run(&msgs, before / 3, None, Some(&*s), &mut state).await;
1994 assert_eq!(out.action, CompressAction::StaticFallback);
1995 }
1996
1997 #[tokio::test]
2002 async fn giant_aged_round_is_pruned_aggressively_not_shipped_over_budget() {
2003 let task = "ACTIVE TASK GAUNTLET-7f3d9c: summarize the three files";
2004 let mut msgs = vec![sys("you are newt"), user(task)];
2005 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2006 {"function": {"name": "read_file", "arguments": {"path": "a.txt"}}},
2007 {"function": {"name": "read_file", "arguments": {"path": "b.txt"}}},
2008 {"function": {"name": "read_file", "arguments": {"path": "c.txt"}}},
2009 ]}));
2010 for _ in 0..3 {
2011 msgs.push(tool_result(&"z".repeat(50_000))); }
2013 msgs.push(assistant_call("read_file", json!({"path": "d.txt"})));
2015 msgs.push(tool_result("short fresh result"));
2016 let mut state = CompressState::new();
2017 let out = run(&msgs, 3_000, None, None, &mut state).await;
2018 assert!(
2019 out.tokens_after <= 3_000,
2020 "the fit pass must bring ~{} under budget, got {}",
2021 out.tokens_before,
2022 out.tokens_after
2023 );
2024 assert!(out.fired);
2025 assert!(out
2027 .messages
2028 .iter()
2029 .any(|m| m["content"].as_str() == Some(task)));
2030 assert_eq!(out.messages[2]["tool_calls"].as_array().unwrap().len(), 3);
2032 assert_eq!(
2033 out.messages
2034 .iter()
2035 .filter(|m| m["role"].as_str() == Some("tool"))
2036 .count(),
2037 4
2038 );
2039 assert_eq!(
2041 out.messages.last().unwrap()["content"].as_str(),
2042 Some("short fresh result")
2043 );
2044 }
2045
2046 #[tokio::test]
2055 async fn fresh_trailing_tool_group_survives_the_aggressive_pass() {
2056 let task = "ACTIVE TASK GAUNTLET-7f3d9c: summarize the three files";
2057 let big = "z".repeat(50_000);
2058 let mut msgs = vec![sys("you are newt"), user(task)];
2059 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2060 {"function": {"name": "read_file", "arguments": {"path": "a.txt"}}},
2061 {"function": {"name": "read_file", "arguments": {"path": "b.txt"}}},
2062 {"function": {"name": "read_file", "arguments": {"path": "c.txt"}}},
2063 ]}));
2064 for _ in 0..3 {
2065 msgs.push(tool_result(&big));
2066 }
2067 let mut state = CompressState::new();
2068 let out = run_count_only(&msgs, 3_000, None, None, &mut state).await;
2069 let results: Vec<&str> = out
2073 .messages
2074 .iter()
2075 .filter(|m| m["role"].as_str() == Some("tool"))
2076 .map(|m| m["content"].as_str().unwrap())
2077 .collect();
2078 assert_eq!(results.len(), 3);
2079 for r in results {
2080 assert_eq!(r, big, "fresh trailing tool results must never be pruned");
2081 }
2082 assert!(
2083 out.tokens_after > 3_000,
2084 "this shape is genuinely incompressible without destroying fresh results"
2085 );
2086 }
2087
2088 #[test]
2096 fn trailing_group_derivation_survives_interleaved_messages() {
2097 let mut msgs = vec![sys("you are newt"), user("task")];
2098 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2099 {"function": {"name": "read_file", "arguments": {"path": "a.rs"}}},
2100 {"function": {"name": "read_file", "arguments": {"path": "b.rs"}}},
2101 ]}));
2102 msgs.push(tool_result("result a"));
2103 msgs.push(tool_result("result b"));
2104 assert_eq!(trailing_tool_group_len(&msgs), 3);
2106 msgs.push(user(
2109 "[3 consecutive read-only rounds with no file writes.]",
2110 ));
2111 assert_eq!(trailing_tool_group_len(&msgs), 4);
2112 msgs.push(summary_message("reference summary"));
2114 assert_eq!(trailing_tool_group_len(&msgs), 5);
2115 msgs.push(json!({"role": "assistant", "content": "thinking…"}));
2117 assert_eq!(trailing_tool_group_len(&msgs), 6);
2118 assert_eq!(trailing_tool_group_len(&[sys("s"), user("t")]), 0);
2120 let roleless = vec![
2123 user("task"),
2124 json!({"content": "", "tool_calls": [
2125 {"function": {"name": "read_file", "arguments": {"path": "a"}}}]}),
2126 tool_result("result a"),
2127 ];
2128 assert_eq!(trailing_tool_group_len(&roleless), 2);
2129 }
2130
2131 #[tokio::test]
2139 async fn nudge_after_fresh_group_does_not_defeat_the_protection() {
2140 let task = "ACTIVE TASK GAUNTLET-7f3d9c: read both files then report";
2141 let unseen1 = format!("1:{}", "u".repeat(8_000));
2142 let unseen2 = format!("2:{}", "v".repeat(8_000));
2143 let mut msgs = vec![sys("you are newt"), user(task)];
2144 for i in 0..6 {
2146 msgs.push(assistant_call(
2147 "read_file",
2148 json!({"path": format!("aged_{i}.rs")}),
2149 ));
2150 msgs.push(tool_result(&format!("{i}:{}", "a".repeat(8_000))));
2151 }
2152 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2154 {"function": {"name": "read_file", "arguments": {"path": "unseen1.rs"}}},
2155 {"function": {"name": "read_file", "arguments": {"path": "unseen2.rs"}}},
2156 ]}));
2157 msgs.push(tool_result(&unseen1));
2158 msgs.push(tool_result(&unseen2));
2159 msgs.push(user(
2161 "[3 consecutive read-only rounds with no file writes. \
2162 Stop exploring. Call edit_file or write_file now.]",
2163 ));
2164 let mut state = CompressState::new();
2165 let out = run_count_only(&msgs, 2_000, None, None, &mut state).await;
2169 assert!(out.fired);
2170 let tool_contents: Vec<&str> = out
2171 .messages
2172 .iter()
2173 .filter(|m| m["role"].as_str() == Some("tool"))
2174 .map(|m| m["content"].as_str().unwrap())
2175 .collect();
2176 assert!(
2177 tool_contents.contains(&unseen1.as_str()),
2178 "#270: UNSEEN1 must survive the nudge-truncated derivation \
2179 (got tool contents {:?})",
2180 tool_contents
2181 .iter()
2182 .map(|c| c.chars().take(40).collect::<String>())
2183 .collect::<Vec<_>>()
2184 );
2185 assert!(
2186 tool_contents.contains(&unseen2.as_str()),
2187 "UNSEEN2 must survive too"
2188 );
2189 assert!(out.messages.iter().any(|m| m["content"]
2191 .as_str()
2192 .is_some_and(|c| c.contains("read-only rounds"))));
2193 println!(
2194 "#270 repro trace: {} -> {} est. tokens (target {}), group intact",
2195 out.tokens_before, out.tokens_after, 2_000
2196 );
2197 }
2198
2199 #[tokio::test]
2202 async fn compaction_notice_after_fresh_group_does_not_defeat_the_protection() {
2203 let task = "ACTIVE TASK GAUNTLET-7f3d9c: read both files then report";
2204 let unseen1 = format!("1:{}", "u".repeat(8_000));
2205 let unseen2 = format!("2:{}", "v".repeat(8_000));
2206 let mut msgs = vec![sys("you are newt"), user(task)];
2207 for i in 0..6 {
2208 msgs.push(assistant_call(
2209 "read_file",
2210 json!({"path": format!("aged_{i}.rs")}),
2211 ));
2212 msgs.push(tool_result(&format!("{i}:{}", "a".repeat(8_000))));
2213 }
2214 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2215 {"function": {"name": "read_file", "arguments": {"path": "unseen1.rs"}}},
2216 {"function": {"name": "read_file", "arguments": {"path": "unseen2.rs"}}},
2217 ]}));
2218 msgs.push(tool_result(&unseen1));
2219 msgs.push(tool_result(&unseen2));
2220 msgs.push(summary_message("## Active Task\nreference summary"));
2221 let mut state = CompressState::new();
2222 let out = run_count_only(&msgs, 2_000, None, None, &mut state).await;
2223 let tool_contents: Vec<&str> = out
2224 .messages
2225 .iter()
2226 .filter(|m| m["role"].as_str() == Some("tool"))
2227 .map(|m| m["content"].as_str().unwrap())
2228 .collect();
2229 assert!(tool_contents.contains(&unseen1.as_str()), "UNSEEN1 intact");
2230 assert!(tool_contents.contains(&unseen2.as_str()), "UNSEEN2 intact");
2231 }
2232
2233 #[test]
2238 fn within_group_reclaim_fires_only_when_group_alone_exceeds() {
2239 let big = "z".repeat(20_000); let small = "s".repeat(1_200); let group = |contents: &[&str]| -> Vec<Value> {
2242 let mut msgs = vec![sys("you are newt"), user("task")];
2243 msgs.push(json!({"role": "assistant", "content": "", "tool_calls":
2244 contents.iter().enumerate().map(|(i, _)| json!(
2245 {"function": {"name": "read_file",
2246 "arguments": {"path": format!("f{i}.txt")}}}
2247 )).collect::<Vec<_>>()
2248 }));
2249 msgs.extend(contents.iter().map(|c| tool_result(c)));
2250 msgs
2251 };
2252
2253 let mut fits = group(&[&small, &small, &small]);
2255 let before = fits.clone();
2256 assert!(!reclaim_within_trailing_group(&mut fits, 10_000, EST));
2257 assert_eq!(fits, before, "a group within its share is never touched");
2258
2259 let mut no_group = vec![sys("s"), user(&big)];
2261 assert!(!reclaim_within_trailing_group(&mut no_group, 100, EST));
2262
2263 let mut single = group(&[&big]);
2267 let before = single.clone();
2268 assert!(!reclaim_within_trailing_group(&mut single, 1_000, EST));
2269 assert_eq!(single, before);
2270
2271 let mut early = group(&[&big, &small, &small]);
2274 assert!(reclaim_within_trailing_group(&mut early, 1_500, EST));
2275 let results: Vec<&str> = early
2276 .iter()
2277 .filter(|m| m["role"].as_str() == Some("tool"))
2278 .map(|m| m["content"].as_str().unwrap())
2279 .collect();
2280 assert!(
2281 results[0].starts_with("[read_file] read 'f0.txt'"),
2282 "oldest one-lined with the re-read affordance: {}",
2283 results[0]
2284 );
2285 assert_eq!(results[1], small, "middle untouched after early stop");
2286 assert_eq!(results[2], small, "newest untouched");
2287 assert!(estimate_tokens(&early, EST) <= 1_500, "the list now fits");
2288
2289 let mut residual = group(&[&small, &small, &big]);
2292 assert!(reclaim_within_trailing_group(&mut residual, 1_000, EST));
2293 let results: Vec<&str> = residual
2294 .iter()
2295 .filter(|m| m["role"].as_str() == Some("tool"))
2296 .map(|m| m["content"].as_str().unwrap())
2297 .collect();
2298 assert!(results[0].starts_with("[read_file] read 'f0.txt'"));
2299 assert!(results[1].starts_with("[read_file] read 'f1.txt'"));
2300 assert_eq!(results[2], big, "the newest member is never a candidate");
2301 assert!(
2302 estimate_tokens(&residual, EST) > 1_000,
2303 "single-result-too-big: truthfully still over budget"
2304 );
2305 }
2306
2307 #[tokio::test]
2315 async fn oversized_group_reclaims_within_keeping_newest_whole() {
2316 let task = "ACTIVE TASK GAUNTLET-7f3d9c: summarize the three files";
2317 let big = "z".repeat(50_000); let mut msgs = vec![sys("you are newt"), user(task)];
2319 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2320 {"function": {"name": "read_file", "arguments": {"path": "a.txt"}}},
2321 {"function": {"name": "read_file", "arguments": {"path": "b.txt"}}},
2322 {"function": {"name": "read_file", "arguments": {"path": "c.txt"}}},
2323 ]}));
2324 for _ in 0..3 {
2325 msgs.push(tool_result(&big));
2326 }
2327 let mut state = CompressState::new();
2328 let out = run(&msgs, 3_000, None, None, &mut state).await;
2329 assert!(out.fired);
2330 let results: Vec<&str> = out
2331 .messages
2332 .iter()
2333 .filter(|m| m["role"].as_str() == Some("tool"))
2334 .map(|m| m["content"].as_str().unwrap())
2335 .collect();
2336 assert_eq!(results.len(), 3, "pairing intact — nothing dropped");
2337 assert!(
2338 results[0].starts_with("[read_file] read 'a.txt'"),
2339 "oldest one-lined, file named for re-read: {}",
2340 results[0]
2341 );
2342 assert!(
2343 results[1].starts_with("[read_file] read 'b.txt'"),
2344 "older one-lined in order: {}",
2345 results[1]
2346 );
2347 assert_eq!(results[2], big, "newest result reaches the model whole");
2348 assert!(out
2350 .messages
2351 .iter()
2352 .any(|m| m["content"].as_str() == Some(task)));
2353 assert!(out.tokens_after > 3_000);
2356 assert!(
2357 out.tokens_after < out.tokens_before / 2,
2358 "but the reclaim was real: {} -> {}",
2359 out.tokens_before,
2360 out.tokens_after
2361 );
2362 println!(
2363 "#285 scenario trace: {} -> {} est. tokens (budget 3000), \
2364 a/b one-lined, c whole",
2365 out.tokens_before, out.tokens_after
2366 );
2367 }
2368
2369 #[tokio::test]
2373 async fn under_budget_group_is_untouched_under_hard_pressure() {
2374 let task = "ACTIVE TASK GAUNTLET-7f3d9c: read both files then report";
2375 let unseen1 = format!("1:{}", "u".repeat(8_000)); let unseen2 = format!("2:{}", "v".repeat(8_000));
2377 let mut msgs = vec![sys("you are newt"), user(task)];
2378 for i in 0..6 {
2379 msgs.push(assistant_call(
2380 "read_file",
2381 json!({"path": format!("aged_{i}.rs")}),
2382 ));
2383 msgs.push(tool_result(&format!("{i}:{}", "a".repeat(8_000))));
2384 }
2385 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2386 {"function": {"name": "read_file", "arguments": {"path": "unseen1.rs"}}},
2387 {"function": {"name": "read_file", "arguments": {"path": "unseen2.rs"}}},
2388 ]}));
2389 msgs.push(tool_result(&unseen1));
2390 msgs.push(tool_result(&unseen2));
2391 msgs.push(user(
2392 "[3 consecutive read-only rounds with no file writes.]",
2393 ));
2394 let mut state = CompressState::new();
2395 let out = run(&msgs, 6_000, None, None, &mut state).await;
2398 assert!(out.fired);
2399 assert!(
2400 out.tokens_after <= 6_000,
2401 "must land under the hard budget ({} -> {})",
2402 out.tokens_before,
2403 out.tokens_after
2404 );
2405 let tool_contents: Vec<&str> = out
2406 .messages
2407 .iter()
2408 .filter(|m| m["role"].as_str() == Some("tool"))
2409 .map(|m| m["content"].as_str().unwrap())
2410 .collect();
2411 assert!(tool_contents.contains(&unseen1.as_str()), "UNSEEN1 whole");
2412 assert!(tool_contents.contains(&unseen2.as_str()), "UNSEEN2 whole");
2413 }
2414
2415 #[tokio::test]
2418 async fn max_messages_forces_summary_stage() {
2419 let msgs = tool_heavy("task", 8, 50); let before = estimate_tokens(&msgs, EST);
2421 let prompts = Arc::new(Mutex::new(Vec::new()));
2422 let s = recording_summarizer(prompts.clone(), "SUMMARY");
2423 let mut state = CompressState::new();
2424 let out = run_count_only(&msgs, before + 1_000, Some(8), Some(&*s), &mut state).await;
2425 assert_eq!(out.action, CompressAction::Summarized);
2426 assert!(out.messages.len() < msgs.len());
2427 }
2428
2429 #[tokio::test]
2435 async fn second_compression_still_shrinks_and_keeps_fresh_results() {
2436 let fresh = format!("9:{}", "x".repeat(4_000));
2437 let msgs = tool_heavy("fix the failing test", 10, 4_000);
2438 let prompts = Arc::new(Mutex::new(Vec::new()));
2439 let s = recording_summarizer(prompts.clone(), "SUMMARY ONE");
2440 let mut state = CompressState::new();
2441 let budget = estimate_tokens(&msgs, EST) / 2;
2442 let first = run_count_only(&msgs, budget, Some(8), Some(&*s), &mut state).await;
2443 assert!(first.messages.len() < msgs.len(), "first pass shrinks");
2444 assert!(first.messages.iter().any(is_compaction_message));
2445
2446 let mut grown = first.messages.clone();
2448 for i in 10..16 {
2449 grown.push(assistant_call(
2450 "read_file",
2451 json!({"path": format!("src/file_{i}.rs")}),
2452 ));
2453 grown.push(tool_result(&format!("{i}:{}", "x".repeat(4_000))));
2454 }
2455 let grown_fresh = grown.last().unwrap()["content"]
2456 .as_str()
2457 .unwrap()
2458 .to_string();
2459 let budget2 = estimate_tokens(&grown, EST) / 2;
2460 let second = run_count_only(&grown, budget2, Some(8), Some(&*s), &mut state).await;
2461 assert!(
2462 second.messages.len() < grown.len(),
2463 "second compression must still shrink ({} -> {})",
2464 grown.len(),
2465 second.messages.len()
2466 );
2467 assert!(
2468 second.messages.len() <= 10,
2469 "count goal must stay reachable, got {}",
2470 second.messages.len()
2471 );
2472 assert_eq!(
2474 first.messages.last().unwrap()["content"].as_str(),
2475 Some(fresh.as_str()),
2476 "first pass fresh result intact"
2477 );
2478 assert_eq!(
2479 second.messages.last().unwrap()["content"].as_str(),
2480 Some(grown_fresh.as_str()),
2481 "second pass fresh result intact"
2482 );
2483 assert!(!state.disabled);
2485 assert_eq!(state.attempts, 0);
2486 }
2487
2488 #[tokio::test]
2492 async fn count_only_never_feeds_or_consults_anti_thrash() {
2493 let mut msgs = vec![sys("you are newt"), user("task")];
2496 for i in 0..10 {
2497 msgs.push(user(&format!("note {i}")));
2498 }
2499 let mut state = CompressState::new();
2500 for _ in 0..4 {
2501 let budget = estimate_tokens(&msgs, EST) / 2;
2502 let out = run_count_only(&msgs, budget, Some(6), None, &mut state).await;
2503 assert_ne!(out.action, CompressAction::Refused);
2504 }
2505 assert!(!state.disabled, "count-only passes must never latch");
2506 assert_eq!(state.attempts, 0, "count-only passes must never record");
2507
2508 let mut latched = CompressState::new();
2510 latched.disabled = true;
2511 latched.notified = true;
2512 let budget = estimate_tokens(&msgs, EST) / 2;
2513 let out = run_count_only(&msgs, budget, Some(6), None, &mut latched).await;
2514 assert_ne!(out.action, CompressAction::Refused);
2515 assert!(
2516 out.messages.len() < msgs.len(),
2517 "the VRAM guard must stay alive while anti-thrash is latched"
2518 );
2519 }
2520
2521 #[test]
2524 fn boundary_head_is_system_plus_original_task() {
2525 let msgs = tool_heavy("the task", 6, 1_000);
2526 let b = compute_boundary(&msgs, 1_000, None, EST);
2527 assert_eq!(b.head, 2, "system + original task");
2528
2529 let mut msgs2 = vec![sys("a"), sys("b"), user("task"), user("more")];
2531 msgs2.extend(tool_heavy("x", 4, 1_000).split_off(2));
2532 assert_eq!(compute_boundary(&msgs2, 1_000, None, EST).head, 3);
2533 }
2534
2535 #[test]
2536 fn boundary_tail_is_token_budgeted_with_minimum() {
2537 let msgs = tool_heavy("task", 10, 1_000);
2539 let b = compute_boundary(&msgs, 4_000, None, EST);
2540 let tail_tokens: usize = msgs[b.tail_start..]
2541 .iter()
2542 .map(|m| estimate_value_tokens(m, EST))
2543 .sum();
2544 assert!(
2545 tail_tokens <= 1_500,
2546 "tail stays near the token budget, got {tail_tokens}"
2547 );
2548 assert!(
2549 msgs.len() - b.tail_start >= TAIL_MIN_MESSAGES,
2550 "at least the minimum tail"
2551 );
2552 assert!(b.tail_start > b.head, "a middle exists to summarize");
2553
2554 let msgs = tool_heavy("task", 6, 40_000);
2556 let b = compute_boundary(&msgs, 4_000, None, EST);
2557 assert!(msgs.len() - b.tail_start >= TAIL_MIN_MESSAGES);
2558 }
2559
2560 #[test]
2561 fn boundary_anchors_last_user_message_into_tail() {
2562 let mut msgs = tool_heavy("task", 2, 500);
2565 msgs.push(user("IMPORTANT FOLLOW-UP: also update the docs"));
2566 let follow_up = msgs.len() - 1;
2567 for i in 0..6 {
2568 msgs.push(assistant_call(
2569 "read_file",
2570 json!({"path": format!("f{i}")}),
2571 ));
2572 msgs.push(tool_result(&"q".repeat(4_000)));
2573 }
2574 let b = compute_boundary(&msgs, 2_000, None, EST);
2575 assert!(
2576 b.tail_start <= follow_up,
2577 "tail (start {}) must include the last user message at {follow_up}",
2578 b.tail_start
2579 );
2580 }
2581
2582 #[test]
2586 fn boundary_anchor_skips_compaction_messages() {
2587 let mut msgs = vec![sys("you are newt"), user("the task")];
2588 msgs.push(summary_message("## Active Task\nthe task (summarized)"));
2589 for i in 0..6 {
2590 msgs.push(assistant_call(
2591 "read_file",
2592 json!({"path": format!("f{i}")}),
2593 ));
2594 msgs.push(tool_result(&"q".repeat(4_000)));
2595 }
2596 let b = compute_boundary(&msgs, 2_000, None, EST);
2597 assert!(
2598 b.tail_start > 2,
2599 "the tail must not pin to the compaction message at index 2 \
2600 (tail_start {})",
2601 b.tail_start
2602 );
2603 let mut msgs2 = msgs.clone();
2605 msgs2.push(user("IMPORTANT FOLLOW-UP: also update the docs"));
2606 let follow_up = msgs2.len() - 1;
2607 for _ in 0..4 {
2608 msgs2.push(assistant_call("read_file", json!({"path": "g"})));
2609 msgs2.push(tool_result(&"q".repeat(4_000)));
2610 }
2611 let b2 = compute_boundary(&msgs2, 2_000, None, EST);
2612 assert!(
2613 b2.tail_start <= follow_up,
2614 "a real user message still anchors the tail"
2615 );
2616 }
2617
2618 #[test]
2624 fn render_message_demotes_loop_guidance_and_narration_echo() {
2625 let nudge = json!({
2627 "role": "user",
2628 "content": format!(
2629 "{LOOP_GUIDANCE_PREFIX} You described what you were about to \
2630 do but did not call any tool, so nothing actually happened."
2631 )
2632 });
2633 let r = render_message(&nudge);
2634 assert!(r.contains("omitted"), "{r}");
2635 assert!(!r.contains("did not call any tool"), "{r}");
2636
2637 let directive = json!({
2639 "role": "user",
2640 "content": format!("{CONTINUATION_PREFIX} You are mid-task…")
2641 });
2642 let r = render_message(&directive);
2643 assert!(r.contains("omitted"), "{r}");
2644 assert!(!r.contains("mid-task"), "{r}");
2645
2646 let echo = json!({
2648 "role": "assistant",
2649 "content": "The user is telling me I keep describing what I'm \
2650 about to do but never call tools. I need to stop \
2651 describing and start acting."
2652 });
2653 let r = render_message(&echo);
2654 assert!(r.contains("omitted"), "{r}");
2655 assert!(!r.contains("keep describing"), "{r}");
2656
2657 let analysis = json!({
2659 "role": "assistant",
2660 "content": "I found the issue: an extra closing brace at line 490."
2661 });
2662 let r = render_message(&analysis);
2663 assert!(r.contains("extra closing brace"), "{r}");
2664
2665 let acting = json!({
2667 "role": "assistant",
2668 "content": "I did not call any tool yet — doing it now.",
2669 "tool_calls": [{"function": {"name": "read_file", "arguments": {"path": "x"}}}]
2670 });
2671 let r = render_message(&acting);
2672 assert!(r.contains("read_file"), "{r}");
2673 assert!(r.contains("doing it now"), "{r}");
2674
2675 let operator = json!({
2677 "role": "user",
2678 "content": "IMPORTANT: also update the docs"
2679 });
2680 let r = render_message(&operator);
2681 assert!(r.contains("update the docs"), "{r}");
2682 }
2683
2684 #[test]
2688 fn summary_prompt_excludes_process_commentary() {
2689 let p = summary_prompt_for("task", "body", None, None, 1_200, ConvShape::Coding);
2690 assert!(
2691 p.contains("Do NOT include commentary about the assistant's own behavior"),
2692 "{p}"
2693 );
2694 assert!(p.contains("record only task state"), "{p}");
2695 }
2696
2697 #[test]
2702 fn boundary_anchor_skips_continuation_directive() {
2703 let mut msgs = vec![sys("you are newt"), user("the task")];
2704 msgs.push(user(&format!(
2705 "{CONTINUATION_PREFIX} You are mid-task: continue with a tool call."
2706 )));
2707 let directive = msgs.len() - 1;
2708 for i in 0..6 {
2709 msgs.push(assistant_call(
2710 "read_file",
2711 json!({"path": format!("f{i}")}),
2712 ));
2713 msgs.push(tool_result(&"q".repeat(4_000)));
2714 }
2715 assert!(is_compaction_message(&msgs[directive]));
2716 assert!(is_continuation_message(&msgs[directive]));
2717 let b = compute_boundary(&msgs, 2_000, None, EST);
2718 assert!(
2719 b.tail_start > directive,
2720 "the tail must not pin to the continuation directive at index \
2721 {directive} (tail_start {})",
2722 b.tail_start
2723 );
2724 }
2725
2726 #[test]
2730 fn boundary_anchor_skips_loop_guidance_nudges() {
2731 let mut msgs = vec![sys("you are newt"), user("the task")];
2732 msgs.push(user("IMPORTANT FOLLOW-UP: also update the docs"));
2733 let operator_ask = msgs.len() - 1;
2734 for i in 0..3 {
2735 msgs.push(assistant_call(
2736 "read_file",
2737 json!({"path": format!("f{i}")}),
2738 ));
2739 msgs.push(tool_result(&"q".repeat(4_000)));
2740 }
2741 msgs.push(user(&format!(
2742 "{LOOP_GUIDANCE_PREFIX} You described what you were about to do \
2743 but did not call any tool…"
2744 )));
2745 let nudge = msgs.len() - 1;
2746 for i in 0..4 {
2747 msgs.push(assistant_call(
2748 "read_file",
2749 json!({"path": format!("g{i}")}),
2750 ));
2751 msgs.push(tool_result(&"q".repeat(4_000)));
2752 }
2753 assert!(is_compaction_message(&msgs[nudge]));
2754 let b = compute_boundary(&msgs, 2_000, None, EST);
2755 assert!(
2756 b.tail_start <= operator_ask,
2757 "the anchor must skip the harness nudge at {nudge} and protect \
2758 the operator's ask at {operator_ask} (tail_start {})",
2759 b.tail_start
2760 );
2761 }
2762
2763 #[test]
2768 fn boundary_count_cap_holds_after_the_anchor() {
2769 let mut msgs = vec![
2770 sys("you are newt"),
2771 user("turn 1"),
2772 json!({"role": "assistant", "content": "reply 1"}),
2773 user("turn 2"),
2774 json!({"role": "assistant", "content": "reply 2"}),
2775 user("the current task"),
2776 ];
2777 let task_idx = msgs.len() - 1;
2778 for i in 0..12 {
2779 msgs.push(assistant_call(
2780 "read_file",
2781 json!({"path": format!("f{i}")}),
2782 ));
2783 msgs.push(tool_result(&"q".repeat(2_000)));
2784 }
2785 let b = compute_boundary(&msgs, 4_000, Some(10), EST);
2786 let assembled = b.head + 1 + (msgs.len() - b.tail_start);
2787 assert!(
2788 assembled <= 12,
2789 "the anchor must not defeat the count goal (assembled {assembled})"
2790 );
2791 assert!(
2792 b.tail_start > task_idx,
2793 "the cut advanced past the deep anchor (tail_start {})",
2794 b.tail_start
2795 );
2796 let b_token = compute_boundary(&msgs, 4_000, None, EST);
2798 assert!(b_token.tail_start <= task_idx);
2799 }
2800
2801 #[test]
2802 fn boundary_never_splits_a_tool_pair() {
2803 for budget in [1_000usize, 2_000, 4_000, 8_000, 16_000] {
2804 let msgs = tool_heavy("task", 8, 2_000);
2805 let b = compute_boundary(&msgs, budget, None, EST);
2806 assert_ne!(
2807 msgs[b.tail_start]["role"].as_str(),
2808 Some("tool"),
2809 "budget {budget}: tail must not start inside a result group"
2810 );
2811 }
2812 }
2813
2814 #[tokio::test]
2817 async fn compress_output_has_no_orphan_tool_pairs() {
2818 let msgs = tool_heavy("task", 8, 2_000);
2819 let mut state = CompressState::new();
2820 let out = run(&msgs, 2_500, None, None, &mut state).await;
2821 let m = &out.messages;
2824 for (i, msg) in m.iter().enumerate() {
2825 if let Some(tcs) = msg["tool_calls"].as_array() {
2826 let mut following = 0;
2827 for next in &m[i + 1..] {
2828 if next["role"].as_str() == Some("tool") {
2829 following += 1;
2830 } else {
2831 break;
2832 }
2833 }
2834 assert_eq!(
2835 following,
2836 tcs.len(),
2837 "message {i}: {} tool_calls need {} contiguous results",
2838 tcs.len(),
2839 tcs.len()
2840 );
2841 }
2842 }
2843 }
2844
2845 #[tokio::test]
2850 async fn anti_thrash_disables_notifies_once_then_refuses() {
2851 let mut msgs = vec![sys(&"s".repeat(4_000)), user("task")];
2854 for i in 0..3 {
2855 msgs.push(user(&format!("note {i}")));
2856 }
2857 let mut state = CompressState::new();
2858
2859 let first = run(&msgs, 100, None, None, &mut state).await;
2860 assert_ne!(first.action, CompressAction::Refused);
2861 assert!(first.notice.is_none(), "one poor pass is not yet thrash");
2862
2863 let second = run(&msgs, 100, None, None, &mut state).await;
2864 let notice = second.notice.expect("second poor pass must notify");
2865 assert!(notice.contains("disabled for this session"), "{notice}");
2866
2867 let third = run(&msgs, 100, None, None, &mut state).await;
2868 assert_eq!(third.action, CompressAction::Refused);
2869 assert!(!third.fired);
2870 assert!(
2871 third.notice.is_none(),
2872 "the notice must be delivered exactly once"
2873 );
2874
2875 let ok = run(&msgs, 100_000, None, None, &mut state).await;
2877 assert_eq!(ok.action, CompressAction::Fit);
2878 }
2879
2880 #[tokio::test]
2888 async fn non_authoritative_budget_fails_open_instead_of_refusing() {
2889 let mut msgs = vec![sys(&"s".repeat(4_000)), user("task")];
2890 for i in 0..3 {
2891 msgs.push(user(&format!("note {i}")));
2892 }
2893 let mut state = CompressState::new();
2894
2895 let first = run_non_authoritative(&msgs, 100, None, None, &mut state).await;
2898 assert_ne!(first.action, CompressAction::Refused);
2899 let _second = run_non_authoritative(&msgs, 100, None, None, &mut state).await;
2900 assert!(state.disabled, "two poor passes must latch the breaker");
2901
2902 let third = run_non_authoritative(&msgs, 100, None, None, &mut state).await;
2904 assert_eq!(third.action, CompressAction::DispatchedOverBudget);
2905 assert!(!third.fired, "messages pass through unchanged");
2906 assert_eq!(third.messages.len(), msgs.len(), "nothing dropped");
2907 let notice = third.notice.expect("fail-open is surfaced once");
2908 assert!(notice.contains("no authoritative window"), "{notice}");
2909
2910 let fourth = run_non_authoritative(&msgs, 100, None, None, &mut state).await;
2912 assert_eq!(fourth.action, CompressAction::DispatchedOverBudget);
2913 assert!(fourth.notice.is_none(), "notice delivered exactly once");
2914 }
2915
2916 #[tokio::test]
2920 async fn authoritative_budget_still_refuses_when_latched() {
2921 let mut msgs = vec![sys(&"s".repeat(4_000)), user("task")];
2922 for i in 0..3 {
2923 msgs.push(user(&format!("note {i}")));
2924 }
2925 let mut state = CompressState::new();
2926 run(&msgs, 100, None, None, &mut state).await;
2927 run(&msgs, 100, None, None, &mut state).await;
2928 assert!(state.disabled);
2929 let third = run(&msgs, 100, None, None, &mut state).await;
2930 assert_eq!(
2931 third.action,
2932 CompressAction::Refused,
2933 "an authoritative ceiling must still refuse, not truncate"
2934 );
2935 }
2936
2937 #[tokio::test]
2943 async fn latched_authoritative_compacts_to_marker_instead_of_refusing() {
2944 let mut msgs = vec![sys("sys"), user("task")];
2945 for i in 0..24 {
2946 msgs.push(user(&format!("middle note {i} {}", "m".repeat(200))));
2947 }
2948 msgs.push(user("recent tail"));
2949 let mut state = CompressState::new();
2950 state.latch_disabled_for_tests();
2951 let budget = 300; let out = run(&msgs, budget, None, None, &mut state).await;
2953 assert_ne!(
2954 out.action,
2955 CompressAction::Refused,
2956 "a reducible middle must compact to a marker, not dead-end"
2957 );
2958 assert!(
2959 out.tokens_after <= budget,
2960 "forced marker compaction must fit the budget ({} > {budget})",
2961 out.tokens_after
2962 );
2963 assert!(out.fired, "the marker compaction changed the working set");
2964 }
2965
2966 #[tokio::test]
2967 async fn compaction_store_captures_redacted_span_and_names_the_handle() {
2968 use crate::agentic::spill::{SessionSpillStore, SpillStore};
2969 let compaction = SessionSpillStore::default();
2973 let mut msgs = vec![sys("sys"), user("task")];
2974 msgs.push(user("config api_key=9f8e7d6c5b4a32100ffee and more"));
2976 for i in 0..24 {
2977 msgs.push(user(&format!("middle note {i} {}", "m".repeat(200))));
2978 }
2979 msgs.push(user("recent tail"));
2980 let mut state = CompressState::new();
2981 let out = compress(
2982 CompressRequest {
2983 messages: &msgs,
2984 budget: 300,
2985 max_messages: None,
2986 task: "task",
2987 hard_budget: true,
2988 authoritative: true,
2989 focus: None,
2990 est: EST,
2991 summary_input_cap_floor_chars: 8_192,
2992 compaction_store: Some(&compaction),
2993 },
2994 None, &mut state,
2996 )
2997 .await;
2998 assert!(out.fired);
2999 assert!(
3001 out.messages.iter().any(|m| m["content"]
3002 .as_str()
3003 .is_some_and(|c| c.contains("compaction:s0"))),
3004 "the marker must name the compaction handle"
3005 );
3006 let span = compaction.fetch("s0").expect("span must be stored");
3008 assert!(
3009 !span.contains("9f8e7d6c5b4a32100ffee"),
3010 "the secret must be redacted before store: {span}"
3011 );
3012 assert!(
3013 span.contains("[REDACTED]"),
3014 "redaction marker present: {span}"
3015 );
3016 }
3017
3018 #[tokio::test]
3019 async fn knowledge_base_stable_base_survives_compression() {
3020 let kb = "## Authoritative import surface\n\
3027 from newt_agent._newt_agent.core import Router # real path, not a guess";
3028 let mut msgs = vec![sys(kb), user("task")];
3029 for i in 0..24 {
3030 msgs.push(user(&format!("middle note {i} {}", "m".repeat(200))));
3031 }
3032 msgs.push(user("recent tail"));
3033 let mut state = CompressState::new();
3034 let out = run(&msgs, 300, None, None, &mut state).await;
3035 assert!(out.fired, "a large conversation should compress");
3036 assert!(
3037 out.messages.iter().any(|m| m["role"] == "system"
3038 && m["content"]
3039 .as_str()
3040 .is_some_and(|c| c.contains("from newt_agent._newt_agent.core import Router"))),
3041 "the knowledge_base import surface must survive compression VERBATIM \
3042 (the protected head — the stable base E relies on)"
3043 );
3044 }
3045
3046 #[tokio::test]
3048 async fn effective_compressions_do_not_disable() {
3049 let mut state = CompressState::new();
3050 for _ in 0..4 {
3051 let msgs = tool_heavy("task", 6, 4_000);
3052 let before = estimate_tokens(&msgs, EST);
3053 let out = run(&msgs, before / 3, None, None, &mut state).await;
3054 assert_ne!(out.action, CompressAction::Refused);
3055 assert!(out.notice.is_none());
3056 }
3057 assert!(!state.disabled);
3058 }
3059
3060 #[test]
3062 fn thrash_window_requires_consecutive_poor_savings() {
3063 let mut state = CompressState::new();
3064 state.record(1_000, 990, 500); state.record(1_000, 400, 500); state.record(1_000, 990, 500); assert!(!state.disabled, "non-consecutive poor passes never disable");
3068 state.record(1_000, 950, 500); assert!(state.disabled);
3070 }
3071
3072 #[test]
3073 fn budget_aware_gap_progress_is_not_a_strike() {
3074 let mut state = CompressState::new();
3078 state.record(1_000, 920, 800);
3080 state.record(1_000, 920, 800);
3081 assert!(
3082 !state.is_disabled(),
3083 "gap-shrinking passes must not latch the disable"
3084 );
3085 let mut dead = CompressState::new();
3088 dead.record(1_000, 995, 500);
3089 dead.record(1_000, 996, 500);
3090 assert!(dead.is_disabled(), "truly ineffective passes still latch");
3091 }
3092
3093 fn chat_history(turns: usize, chars: usize) -> Vec<Value> {
3098 let mut msgs = vec![sys("you are newt"), user("ORIGINAL TASK: port the parser")];
3099 for i in 0..turns {
3100 msgs.push(user(&format!("q{i} {}", "u".repeat(chars))));
3101 msgs.push(json!({"role": "assistant",
3102 "content": format!("a{i} {}", "v".repeat(chars))}));
3103 }
3104 msgs
3105 }
3106
3107 #[tokio::test]
3111 async fn user_initiated_compresses_without_token_pressure() {
3112 let msgs = chat_history(10, 400);
3113 let prompts = Arc::new(Mutex::new(Vec::new()));
3114 let s = recording_summarizer(prompts.clone(), "## Active Task\nMANUAL SUMMARY");
3115 let mut state = CompressState::new();
3116 let out = compress_user_initiated(&msgs, None, Some(&*s), &mut state, EST, 8_192).await;
3117
3118 assert!(out.fired);
3119 assert_eq!(out.how, CompressAction::Summarized.describe());
3120 assert_eq!(out.messages_before, msgs.len());
3121 assert_eq!(out.messages_after, out.messages.len());
3122 assert!(
3123 out.messages_after < out.messages_before,
3124 "count must shrink"
3125 );
3126 assert!(out.tokens_after < out.tokens_before);
3127 assert!(
3128 out.messages.iter().any(|m| is_compaction_message(m)
3129 && m["content"].as_str().unwrap().contains("MANUAL SUMMARY")),
3130 "marked summary message must be present"
3131 );
3132 let p = prompts.lock().unwrap();
3134 assert!(p[0].contains("ORIGINAL TASK: port the parser"), "{}", p[0]);
3135 let c = state.counters();
3137 assert_eq!(c.compressions, 1);
3138 assert_eq!(c.strikes, 0, "a good reclaim is not a strike");
3139 assert!(c.last_reclaim.unwrap() > THRASH_MIN_SAVINGS);
3140 assert!(!c.disabled);
3141 }
3142
3143 #[tokio::test]
3147 async fn user_initiated_focus_is_threaded_and_redacted() {
3148 let msgs = chat_history(10, 400);
3149 let prompts = Arc::new(Mutex::new(Vec::new()));
3150 let s = recording_summarizer(prompts.clone(), "SUMMARY");
3151 let mut state = CompressState::new();
3152 let secret = "sk-aaaaaaaaaaaaaaaaaaaaaaaa1234";
3153 let focus = format!("the auth flow around {secret} handling");
3154 let out =
3155 compress_user_initiated(&msgs, Some(&focus), Some(&*s), &mut state, EST, 8_192).await;
3156 assert!(out.fired);
3157
3158 let p = prompts.lock().unwrap();
3159 assert_eq!(p.len(), 1);
3160 assert!(
3161 p[0].contains("emphasize anything about"),
3162 "focus guidance line missing: {}",
3163 p[0]
3164 );
3165 assert!(p[0].contains("the auth flow around"), "{}", p[0]);
3166 assert!(
3167 !p[0].contains(secret),
3168 "a secret typed into the focus must never reach the summarizer"
3169 );
3170 assert!(p[0].contains("[REDACTED]"));
3171 }
3172
3173 #[tokio::test]
3176 async fn no_focus_means_no_guidance_line() {
3177 let msgs = chat_history(10, 400);
3178 let prompts = Arc::new(Mutex::new(Vec::new()));
3179 let s = recording_summarizer(prompts.clone(), "SUMMARY");
3180 let mut state = CompressState::new();
3181 compress_user_initiated(&msgs, None, Some(&*s), &mut state, EST, 8_192).await;
3182 assert!(!prompts.lock().unwrap()[0].contains("emphasize anything about"));
3183 }
3184
3185 #[tokio::test]
3189 async fn user_initiated_noop_records_nothing() {
3190 let msgs = vec![sys("you are newt"), user("task"), user("note")];
3191 let mut state = CompressState::new();
3192 for _ in 0..3 {
3193 let out = compress_user_initiated(&msgs, None, None, &mut state, EST, 8_192).await;
3194 assert!(!out.fired, "nothing to reclaim — must not fire");
3195 assert_eq!(out.messages, msgs);
3196 assert_eq!(out.tokens_before, out.tokens_after);
3197 assert!(out.notice.is_none());
3198 }
3199 let c = state.counters();
3200 assert_eq!(c.compressions, 0, "no-op runs never count");
3201 assert_eq!(c.strikes, 0);
3202 assert!(!c.disabled);
3203 assert_eq!(c.last_reclaim, None);
3204 }
3205
3206 #[tokio::test]
3210 async fn user_initiated_runs_while_latched() {
3211 let msgs = chat_history(10, 400);
3212 let mut state = CompressState::new();
3213 state.latch_disabled_for_tests();
3214 let out = compress_user_initiated(&msgs, None, None, &mut state, EST, 8_192).await;
3215 assert!(out.fired, "an explicit ask must bypass the latch");
3216 assert_eq!(out.how, CompressAction::StaticFallback.describe());
3217 assert!(state.is_disabled(), "the latch itself stays set");
3218 }
3219
3220 #[test]
3222 fn counters_snapshot_projects_state() {
3223 let mut state = CompressState::new();
3224 let c = state.counters();
3225 assert_eq!((c.compressions, c.strikes, c.disabled), (0, 0, false));
3226 assert_eq!(c.last_reclaim, None);
3227
3228 state.record(1_000, 400, 500); let c = state.counters();
3230 assert_eq!((c.compressions, c.strikes, c.disabled), (1, 0, false));
3231 assert!((c.last_reclaim.unwrap() - 0.6).abs() < 0.01);
3232
3233 state.record(1_000, 990, 500); let c = state.counters();
3235 assert_eq!((c.compressions, c.strikes, c.disabled), (2, 1, false));
3236
3237 state.record(1_000, 950, 500); let c = state.counters();
3239 assert_eq!((c.compressions, c.strikes, c.disabled), (3, 2, true));
3240 assert!(c.last_reclaim.unwrap() < THRASH_MIN_SAVINGS);
3241 }
3242
3243 #[test]
3246 fn counters_first_poor_attempt_is_one_strike() {
3247 let mut state = CompressState::new();
3248 state.record(1_000, 990, 500);
3249 assert_eq!(state.counters().strikes, 1);
3250 }
3251
3252 #[test]
3255 fn trigger_fires_on_count_token_or_guard() {
3256 assert!(compression_trigger(10, 1_000, 900, 40, None, None, 100).is_none());
3258 assert_eq!(
3260 compression_trigger(4, 60_000, 59_000, 40, Some(50_000), None, 100),
3261 Some(CompressTrigger {
3262 budget: 50_000,
3263 max_messages: None,
3264 hard_budget: true,
3265 })
3266 );
3267 assert_eq!(
3269 compression_trigger(4, 9_000, 8_600, 40, None, Some(8_000), 500),
3270 Some(CompressTrigger {
3271 budget: 7_500,
3272 max_messages: None,
3273 hard_budget: true,
3274 })
3275 );
3276 assert_eq!(
3280 compression_trigger(41, 1_000, 800, 40, None, None, 100),
3281 Some(CompressTrigger {
3282 budget: 400,
3283 max_messages: Some(20),
3284 hard_budget: false,
3285 })
3286 );
3287 assert_eq!(
3289 compression_trigger(41, 60_000, 59_000, 40, Some(50_000), Some(20_000), 500),
3290 Some(CompressTrigger {
3291 budget: 19_500,
3292 max_messages: Some(20),
3293 hard_budget: true,
3294 })
3295 );
3296 assert!(compression_trigger(4, 7_999, 7_000, 40, Some(50_000), Some(8_000), 0).is_none());
3298 }
3299
3300 #[test]
3304 fn trigger_zero_token_budget_is_disabled() {
3305 assert!(compression_trigger(4, 100, 90, 40, Some(0), None, 0).is_none());
3306 assert!(compression_trigger(4, 100, 90, 40, None, Some(0), 10).is_none());
3307 assert_eq!(
3309 compression_trigger(41, 100, 90, 40, Some(0), Some(0), 10),
3310 Some(CompressTrigger {
3311 budget: 45,
3312 max_messages: Some(20),
3313 hard_budget: false,
3314 })
3315 );
3316 }
3317
3318 #[test]
3321 fn redaction_catches_true_positives() {
3322 let cases = [
3323 (
3324 "the key is sk-AbCdEf1234567890AbCdEf1234567890",
3325 "sk-AbCdEf",
3326 ),
3327 ("ghp_AbCdEf1234567890AbCdEf1234567890", "ghp_"),
3328 ("github_pat_11ABCDEFG0123456789_abcdefghij", "github_pat_"),
3329 ("aws id AKIAIOSFODNN7EXAMPLE", "AKIAIOSFODNN7"),
3330 (
3331 "Authorization: Bearer abc.def-ghi_jkl012345678901234567890",
3332 "abc.def-ghi",
3333 ),
3334 ("api_key=9f8e7d6c5b4a32100ffee", "9f8e7d6c"),
3335 ("password: \"hunter2hunter2\"", "hunter2hunter2"),
3336 (
3337 "jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc123def456",
3338 "eyJhbGci",
3339 ),
3340 ];
3341 for (input, leaked) in cases {
3342 let out = redact_secrets(input);
3343 assert!(
3344 !out.contains(leaked),
3345 "secret fragment {leaked:?} survived: {out}"
3346 );
3347 assert!(out.contains("[REDACTED]"), "no redaction marker: {out}");
3348 }
3349 let key = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow…\n-----END RSA PRIVATE KEY-----";
3351 assert!(!redact_secrets(key).contains("MIIEow"));
3352 let cut = "-----BEGIN PRIVATE KEY-----\nMIIEow… (truncated)";
3353 assert!(!redact_secrets(cut).contains("MIIEow"));
3354 }
3355
3356 #[test]
3357 fn redaction_passes_benign_near_misses() {
3358 let benign = [
3359 "the api key is stored in the system keychain",
3360 "the token budget is 4096 tokens per request",
3361 "Bearer of good news: the build is green",
3362 "sk-test was rejected (too short to be a real key)",
3363 "set password: yes in sshd_config",
3364 "AKIAFOO is not a full key id",
3365 "ghp_short",
3366 "the access_token field is documented in docs/api.md",
3367 "run `cargo test -p newt-core` and check the password prompt",
3368 ];
3369 for input in benign {
3370 let out = redact_secrets(input);
3371 assert_eq!(out, input, "benign text must pass unchanged");
3372 }
3373 }
3374
3375 #[test]
3376 fn redaction_applies_inside_the_summary_request() {
3377 let middle = vec![tool_result(
3378 "config: api_key=9f8e7d6c5b4a32100ffee and more text",
3379 )];
3380 let request = redact_secrets(&summary_request(
3381 "the task",
3382 &middle,
3383 usize::MAX,
3384 None,
3385 ConvShape::Coding,
3386 ));
3387 assert!(!request.contains("9f8e7d6c5b4a32100ffee"), "{request}");
3388 assert!(request.contains("api_key=[REDACTED]"), "{request}");
3389 assert!(request.contains("the task"), "task still present verbatim");
3390 }
3391
3392 #[test]
3393 fn middle_shape_detects_coding_vs_general() {
3394 let coding = vec![serde_json::json!({
3396 "role": "assistant",
3397 "tool_calls": [{"function": {"name": "edit_file", "arguments": "{}"}}],
3398 })];
3399 assert_eq!(middle_shape(&coding), ConvShape::Coding);
3400 let general = vec![
3401 serde_json::json!({"role": "user", "content": "what is a monad?"}),
3402 serde_json::json!({"role": "assistant", "content": "a monoid in ..."}),
3403 ];
3404 assert_eq!(middle_shape(&general), ConvShape::General);
3405 }
3406
3407 #[test]
3408 fn general_shape_swaps_the_section_template() {
3409 let coding = summary_prompt_for("t", "body", None, None, 600, ConvShape::Coding);
3412 assert!(coding.contains("## Completed Actions") && coding.contains("## Relevant Files"));
3413 let general = summary_prompt_for("t", "body", None, None, 600, ConvShape::General);
3414 assert!(general.contains("## Discussion") && general.contains("## Open Questions"));
3415 assert!(
3416 !general.contains("## Relevant Files"),
3417 "no file-centric slot for a Q&A middle"
3418 );
3419 assert!(general.contains("## Active Task") && general.contains("## Critical Context"));
3420 assert!(general.starts_with("You are compressing the middle of a conversation."));
3421 }
3422
3423 #[test]
3426 fn redaction_catches_json_quoted_credential_keys() {
3427 let cases = [
3428 (r#"{"api_key": "9f8e7d6c5b4a32100ffee"}"#, "9f8e7d6c"),
3429 (r#"{"password": "hunter2hunter2"}"#, "hunter2hunter2"),
3430 (
3431 r#"body: "client_secret": "abcd1234efgh5678ijkl""#,
3432 "abcd1234",
3433 ),
3434 ];
3435 for (input, leaked) in cases {
3436 let out = redact_secrets(input);
3437 assert!(
3438 !out.contains(leaked),
3439 "secret fragment {leaked:?} survived: {out}"
3440 );
3441 assert!(out.contains("[REDACTED]"), "no redaction marker: {out}");
3442 }
3443 }
3444
3445 #[test]
3449 fn redaction_survives_excerpt_truncation() {
3450 let secret = "sk-AbCdEf1234567890AbCdEf1234567890";
3451 let args = json!({
3454 "command": format!("{} && export OPENAI_API_KEY={secret}", "x".repeat(140))
3455 });
3456 let m = assistant_call("run_command", args);
3457 let line = render_message(&m);
3458 assert!(!line.contains("sk-AbC"), "{line}");
3459 assert!(!line.contains("AbCdEf123"), "no fragment may leak: {line}");
3460 assert!(line.contains("[REDACTED]"), "{line}");
3461 }
3462
3463 #[test]
3468 fn summary_request_caps_total_middle_size() {
3469 let middle: Vec<Value> = (0..50)
3470 .map(|i| tool_result(&format!("MSG{i} {}", "m".repeat(1_900))))
3471 .collect();
3472 let capped = summary_request("the task", &middle, 8_192, None, ConvShape::Coding);
3473 assert!(
3474 capped.chars().count() < 12_000,
3475 "total must be capped, got {}",
3476 capped.chars().count()
3477 );
3478 assert!(capped.contains("older message(s) omitted"), "{capped:.200}");
3479 assert!(capped.contains("MSG49 "), "most recent middle kept");
3480 assert!(!capped.contains("MSG0 "), "oldest middle dropped");
3481 assert!(capped.contains("the task"), "task always present");
3482
3483 let uncapped = summary_request("the task", &middle, usize::MAX, None, ConvShape::Coding);
3485 assert!(uncapped.chars().count() > 90_000);
3486 assert!(!uncapped.contains("older message(s) omitted"));
3487 }
3488
3489 #[test]
3492 fn chunk_strings_groups_consecutive_within_cap() {
3493 let parts: Vec<String> = ["aaa", "bbb", "ccc", "ddddddd"]
3494 .iter()
3495 .map(|s| s.to_string())
3496 .collect();
3497 assert_eq!(
3500 chunk_strings(&parts, 6),
3501 vec![
3502 "aaabbb".to_string(),
3503 "ccc".to_string(),
3504 "ddddddd".to_string()
3505 ]
3506 );
3507 assert_eq!(chunk_strings(&parts, 1_000).len(), 1);
3509 }
3510
3511 #[tokio::test]
3512 async fn summarize_middle_single_request_when_it_fits() {
3513 let prompts = Arc::new(Mutex::new(Vec::new()));
3514 let s = recording_summarizer(prompts.clone(), "SUMMARY");
3515 let middle = vec![user("alpha"), user("beta")];
3516 let out = summarize_middle(&*s, "do the task", &middle, 100_000, None).await;
3517 assert_eq!(out.as_deref(), Some("SUMMARY"));
3518 assert_eq!(prompts.lock().unwrap().len(), 1, "fits → one request");
3519 }
3520
3521 #[tokio::test]
3522 async fn summarize_middle_chunks_and_reduces_when_over_cap() {
3523 let prompts = Arc::new(Mutex::new(Vec::new()));
3524 let s = recording_summarizer(prompts.clone(), "PART");
3525 let big = "x".repeat(1_000);
3528 let middle: Vec<Value> = (0..6).map(|_| user(&big)).collect();
3529 let out = summarize_middle(&*s, "do the task", &middle, 2_500, None).await;
3530 assert_eq!(out.as_deref(), Some("PART"), "result is the reduce output");
3531 let p = prompts.lock().unwrap();
3532 assert!(
3533 p.len() > 1,
3534 "over-cap middle is chunked: {} requests",
3535 p.len()
3536 );
3537 assert!(
3538 p.iter().any(|r| r.contains("[part 1/")),
3539 "chunks carry part labels"
3540 );
3541 assert!(
3542 p.iter().any(|r| r.contains("consolidate")),
3543 "a reduce/consolidation pass ran"
3544 );
3545 assert!(
3548 p.iter().all(|r| r.chars().count() < 2_500 + 2_000),
3549 "each request stays under the cap (+ template)"
3550 );
3551 }
3552
3553 #[tokio::test]
3554 async fn summarize_middle_all_chunks_fail_degrades_to_none() {
3555 let calls = Arc::new(AtomicUsize::new(0));
3556 let s = failing_summarizer(calls.clone());
3557 let big = "x".repeat(1_000);
3558 let middle: Vec<Value> = (0..6).map(|_| user(&big)).collect();
3559 let out = summarize_middle(&*s, "task", &middle, 2_500, None).await;
3560 assert!(out.is_none(), "all chunks failing → None (→ static marker)");
3561 assert!(
3562 calls.load(Ordering::SeqCst) >= 3,
3563 "every chunk was attempted, got {}",
3564 calls.load(Ordering::SeqCst)
3565 );
3566 }
3567
3568 #[test]
3571 fn render_message_includes_calls_and_caps_content() {
3572 let m = assistant_call("read_file", json!({"path": "src/lib.rs"}));
3573 let line = render_message(&m);
3574 assert!(line.starts_with("[assistant] called read_file("), "{line}");
3575 assert!(line.contains("src/lib.rs"), "{line}");
3576
3577 let long = tool_result(&"w".repeat(10_000));
3578 let line = render_message(&long);
3579 assert!(
3580 line.chars().count() < SUMMARY_INPUT_MSG_CAP + 50,
3581 "{}",
3582 line.len()
3583 );
3584 assert!(line.contains('…'));
3585 }
3586}