1use std::path::Path;
14use std::sync::{Arc, Mutex, RwLock};
15
16use crate::message::Message;
17use crate::tools::fs::ReadFileState;
18use crate::tools::plan_mode::PlanApprovalGate;
19use crate::tools::todo::{TodoItem, TodoStatus};
20
21#[derive(Debug, Clone)]
26pub struct FileReinjector {
27 pub max_files: usize,
29 pub token_budget: usize,
32 pub per_file_budget: usize,
34 pub read_state: Arc<Mutex<ReadFileState>>,
36}
37
38impl FileReinjector {
39 pub fn new(read_state: Arc<Mutex<ReadFileState>>) -> Self {
42 Self {
43 max_files: 5,
44 token_budget: 50_000,
45 per_file_budget: 5_000,
46 read_state,
47 }
48 }
49
50 pub fn reinject(&self, preserved: &[Message]) -> Vec<Message> {
59 let locked = match self.read_state.lock() {
60 Ok(s) => s,
61 Err(_) => return Vec::new(), };
63
64 let recent = locked.recent_files(self.max_files);
65 drop(locked);
67
68 let mut result: Vec<Message> = Vec::new();
69 let mut tokens_used: usize = 0;
70
71 for (path, content) in recent {
72 if path_appears_in_preserved(&path, preserved) {
75 continue;
76 }
77
78 let max_chars = self.per_file_budget * 4;
80 let truncated = if content.len() > max_chars {
81 format!(
82 "{}...\n[... truncated for compaction; use Read on the path for full text]",
83 &content[..max_chars]
84 )
85 } else {
86 content
87 };
88
89 let att_len_chars = truncated.len();
90 let att_tokens = att_len_chars / 4 + 1; if tokens_used + att_tokens > self.token_budget {
94 continue;
95 }
96 tokens_used += att_tokens;
97
98 let msg_text = format!(
99 "[post-compact file restore: {}]\n{}",
100 path.display(),
101 truncated
102 );
103 result.push(Message::system(msg_text));
104 }
105
106 result
107 }
108}
109
110fn path_appears_in_preserved(path: &Path, preserved: &[Message]) -> bool {
118 let path_str = path.to_string_lossy();
119 preserved.iter().any(|m| {
120 if m.role == crate::message::Role::Tool {
121 m.content.contains(path_str.as_ref())
122 } else {
123 false
124 }
125 })
126}
127
128pub fn build_file_reinjector_from_env(
140 read_state: Arc<Mutex<ReadFileState>>,
141) -> Option<FileReinjector> {
142 let raw = std::env::var("RECURSIVE_REINJECT_FILES").ok();
143 let max_files: Option<usize> = match raw.as_deref() {
144 Some("0") | Some("off") | Some("false") => None,
145 Some(s) => s.parse::<usize>().ok().filter(|&n| n > 0),
146 None => Some(5), };
148 let max_files = max_files?; let budget_raw = std::env::var("RECURSIVE_REINJECT_FILE_BUDGET").ok();
151 let token_budget: usize = match budget_raw.as_deref() {
152 Some(s) => s.parse::<usize>().ok().filter(|&n| n > 0).unwrap_or(50_000),
153 None => 50_000,
154 };
155
156 Some(FileReinjector {
157 max_files,
158 token_budget,
159 per_file_budget: 5_000,
160 read_state,
161 })
162}
163
164#[derive(Debug, Clone)]
182pub struct SkillReinjector {
183 pub token_budget: usize,
186 pub per_skill_budget: usize,
188 pub skills: Vec<crate::skills::Skill>,
190}
191
192impl SkillReinjector {
193 pub fn new(skills: Vec<crate::skills::Skill>) -> Self {
196 Self {
197 token_budget: 25_000,
198 per_skill_budget: 5_000,
199 skills,
200 }
201 }
202
203 pub fn reinject(
211 &self,
212 pre_compact: &[crate::message::Message],
213 ) -> Vec<crate::message::Message> {
214 use std::collections::HashSet;
215
216 if self.skills.is_empty() {
217 return Vec::new();
218 }
219
220 let skill_names: Vec<String> = {
222 let mut seen: HashSet<String> = HashSet::new();
223 let mut ordered: Vec<String> = Vec::new();
224
225 for msg in pre_compact {
226 if msg.role != crate::message::Role::Assistant {
227 continue;
228 }
229 for tc in &msg.tool_calls {
230 let is_skill_call =
231 tc.name == "Skill" || tc.name == "LoadSkill" || tc.name == "load_skill";
232 if !is_skill_call {
233 continue;
234 }
235 let skill_name = match tc.arguments.get("name").and_then(|v| v.as_str()) {
237 Some(n) => n.to_lowercase(),
238 None => continue,
239 };
240 if seen.insert(skill_name.clone()) {
241 ordered.push(skill_name);
242 }
243 }
244 }
245 ordered
246 };
247
248 if skill_names.is_empty() {
249 return Vec::new();
250 }
251
252 let mut candidates: Vec<(String, String, String)> = Vec::new();
254 for name_lower in &skill_names {
255 let skill = match self
256 .skills
257 .iter()
258 .find(|s| s.name.to_lowercase() == *name_lower)
259 {
260 Some(s) => s,
261 None => continue,
262 };
263
264 let content = match std::fs::read_to_string(&skill.path) {
265 Ok(c) => c,
266 Err(_) => continue,
267 };
268 let body = crate::skills::extract_skill_body(&content).to_string();
269
270 let max_chars = self.per_skill_budget.saturating_mul(4);
272 let body = if body.len() > max_chars {
273 format!(
274 "{}...\n\n[... skill content truncated for compaction; use Read on the skill path if you need the full text]",
275 &body[..max_chars]
276 )
277 } else {
278 body
279 };
280
281 candidates.push((
282 skill.name.clone(),
283 skill.path.to_string_lossy().to_string(),
284 body,
285 ));
286 }
287
288 if candidates.is_empty() {
289 return Vec::new();
290 }
291
292 candidates.reverse();
294
295 let mut result: Vec<crate::message::Message> = Vec::new();
296 let mut tokens_used: usize = 0;
297
298 for (name, path, body) in candidates {
299 let att_text = format!("[post-compact skill restore: {name} @ {path}]\n{body}");
300 let att_chars = att_text.len();
301 let att_tokens = att_chars / 4 + 1;
302
303 if tokens_used + att_tokens > self.token_budget {
304 continue;
305 }
306 tokens_used += att_tokens;
307 result.push(crate::message::Message::system(att_text));
308 }
309
310 result
311 }
312}
313
314pub fn build_skill_reinjector_from_env(
328 skills: Vec<crate::skills::Skill>,
329) -> Option<SkillReinjector> {
330 let raw = std::env::var("RECURSIVE_REINJECT_SKILLS").ok();
331 match raw.as_deref() {
332 Some("0") | Some("off") | Some("false") => return None,
333 _ => {}
334 }
335
336 let token_budget: usize = match raw.as_deref() {
337 Some(s) => s.parse::<usize>().ok().filter(|&n| n > 0).unwrap_or(25_000),
338 None => {
339 let budget_raw = std::env::var("RECURSIVE_REINJECT_SKILL_BUDGET").ok();
340 match budget_raw.as_deref() {
341 Some(s) => s.parse::<usize>().ok().filter(|&n| n > 0).unwrap_or(25_000),
342 None => 25_000,
343 }
344 }
345 };
346
347 if token_budget == 0 {
348 return None;
349 }
350
351 Some(SkillReinjector {
352 token_budget,
353 per_skill_budget: 5_000,
354 skills,
355 })
356}
357
358#[derive(Debug, Clone)]
374pub struct PlanTodoReinjector {
375 pub todos: Arc<RwLock<Vec<TodoItem>>>,
377 pub plan_gate: Arc<PlanApprovalGate>,
380}
381
382impl PlanTodoReinjector {
383 pub fn new(todos: Arc<RwLock<Vec<TodoItem>>>, plan_gate: Arc<PlanApprovalGate>) -> Self {
385 Self { todos, plan_gate }
386 }
387
388 pub fn reinject(&self) -> Vec<Message> {
393 let mut result: Vec<Message> = Vec::new();
394
395 match self.plan_gate.pending_plan() {
397 Some(plan) => {
398 let msg = format!(
399 "[post-compact plan restore]\n\
400 You are in plan mode. The pending plan (awaiting approval) is:\n\
401 {}",
402 plan
403 );
404 result.push(Message::system(msg));
405 }
406 None => {
407 }
409 }
410
411 let todo_items: Vec<TodoItem> = match self.todos.read() {
413 Ok(guard) => guard.clone(),
414 Err(_) => {
415 tracing::warn!("PlanTodoReinjector: todo list lock poisoned, skipping todos");
416 Vec::new()
417 }
418 };
419
420 if !todo_items.is_empty() {
421 let mut list_body = String::from("[post-compact todo restore]\nCurrent task list:\n");
422 for item in &todo_items {
423 let checkbox = match item.status {
424 TodoStatus::Completed => "[x]",
425 TodoStatus::Cancelled => "[~]",
426 TodoStatus::Pending => "[ ]",
427 TodoStatus::InProgress => "[/]",
428 };
429 list_body.push_str(&format!("- {} {}\n", checkbox, item.content));
430 if item.status == TodoStatus::InProgress {
431 if let Some(ref active) = item.active_form {
432 list_body.push_str(&format!(" (active: {})\n", active));
433 }
434 }
435 }
436 result.push(Message::system(list_body));
437 }
438
439 result
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use std::path::PathBuf;
447
448 static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
453 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
454 ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner())
455 }
456
457 fn make_state_with(files: Vec<(&str, &str)>) -> Arc<Mutex<ReadFileState>> {
458 let state = Arc::new(Mutex::new(ReadFileState::new()));
459 let mut locked = state.lock().unwrap();
460 for (path, content) in files {
461 locked.record(PathBuf::from(path), false, content.to_string(), 1000);
462 }
463 drop(locked);
464 state
465 }
466
467 #[test]
470 fn reinject_returns_recent_files_as_system_messages() {
471 let state = make_state_with(vec![("src/lib.rs", "pub fn foo() {}")]);
472 let r = FileReinjector::new(state);
473 let msgs = r.reinject(&[]);
474 assert_eq!(msgs.len(), 1);
475 assert_eq!(msgs[0].role, crate::message::Role::System);
476 assert!(msgs[0].content.contains("src/lib.rs"));
477 assert!(msgs[0].content.contains("pub fn foo() {}"));
478 assert!(msgs[0].content.starts_with("[post-compact file restore:"));
479 }
480
481 #[test]
484 fn reinject_respects_max_files() {
485 let state = make_state_with(vec![("a.rs", "a"), ("b.rs", "b"), ("c.rs", "c")]);
486 let r = FileReinjector {
487 max_files: 2,
488 ..FileReinjector::new(state)
489 };
490 let msgs = r.reinject(&[]);
491 assert_eq!(msgs.len(), 2);
492 assert!(msgs[0].content.contains("c.rs"));
494 assert!(msgs[1].content.contains("b.rs"));
495 }
496
497 #[test]
500 fn reinject_respects_token_budget() {
501 let big = "A".repeat(9_000);
505 let state = make_state_with(vec![("big1.rs", &big), ("big2.rs", &big)]);
506 let r = FileReinjector {
507 token_budget: 2500,
508 per_file_budget: 5000,
509 ..FileReinjector::new(state)
510 };
511 let msgs = r.reinject(&[]);
512 assert_eq!(
513 msgs.len(),
514 1,
515 "budget 2500 should fit exactly one ~2251-token file"
516 );
517 }
518
519 #[test]
522 fn reinject_truncates_oversized_file() {
523 let huge = "X".repeat(100_000);
525 let state = make_state_with(vec![("huge.rs", &huge)]);
526 let r = FileReinjector::new(state);
527 let msgs = r.reinject(&[]);
528 assert_eq!(msgs.len(), 1);
529 let content = &msgs[0].content;
530 assert!(content.contains("[post-compact file restore:"));
531 assert!(
532 content.contains("[... truncated for compaction; use Read on the path for full text]")
533 );
534 assert!(
536 content.len() < huge.len() + 150,
537 "truncated content should be shorter than original: {} < {}",
538 content.len(),
539 huge.len()
540 );
541 }
542
543 #[test]
546 fn reinject_dedups_against_preserved_tail() {
547 let state = make_state_with(vec![("src/lib.rs", "pub fn foo() {}")]);
548 let preserved = vec![Message::tool_result("c1", "src/lib.rs was read and ...")];
549 let r = FileReinjector::new(state);
550 let msgs = r.reinject(&preserved);
551 assert!(
552 msgs.is_empty(),
553 "file whose path appears in a preserved Tool message must be skipped"
554 );
555 }
556
557 #[test]
560 fn reinject_empty_when_no_files() {
561 let state = Arc::new(Mutex::new(ReadFileState::new()));
562 let r = FileReinjector::new(state);
563 let msgs = r.reinject(&[]);
564 assert!(
565 msgs.is_empty(),
566 "empty ReadFileState must produce empty Vec"
567 );
568 }
569
570 fn with_env_var(name: &str, value: Option<&str>, f: impl FnOnce()) {
576 let prev = std::env::var(name).ok();
577 match value {
578 Some(v) => std::env::set_var(name, v),
579 None => std::env::remove_var(name),
580 }
581 f();
582 match prev {
583 Some(v) => std::env::set_var(name, v),
584 None => std::env::remove_var(name),
585 }
586 }
587
588 #[test]
589 fn build_file_reinjector_disabled_when_env_zero() {
590 let _g = env_guard();
591 let state = Arc::new(Mutex::new(ReadFileState::new()));
592 with_env_var("RECURSIVE_REINJECT_FILES", Some("0"), || {
593 assert!(build_file_reinjector_from_env(state.clone()).is_none());
594 });
595 }
596
597 #[test]
598 fn build_file_reinjector_disabled_when_env_off() {
599 let _g = env_guard();
600 let state = Arc::new(Mutex::new(ReadFileState::new()));
601 with_env_var("RECURSIVE_REINJECT_FILES", Some("off"), || {
602 assert!(build_file_reinjector_from_env(state.clone()).is_none());
603 });
604 }
605
606 #[test]
607 fn build_file_reinjector_disabled_when_env_false() {
608 let _g = env_guard();
609 let state = Arc::new(Mutex::new(ReadFileState::new()));
610 with_env_var("RECURSIVE_REINJECT_FILES", Some("false"), || {
611 assert!(build_file_reinjector_from_env(state.clone()).is_none());
612 });
613 }
614
615 #[test]
616 fn build_file_reinjector_explicit_count() {
617 let _g = env_guard();
618 let state = Arc::new(Mutex::new(ReadFileState::new()));
619 with_env_var("RECURSIVE_REINJECT_FILES", Some("3"), || {
620 let r = build_file_reinjector_from_env(state.clone())
621 .expect("explicit positive count must yield Some");
622 assert_eq!(r.max_files, 3);
623 assert_eq!(r.token_budget, 50_000);
624 });
625 }
626
627 #[test]
628 fn build_file_reinjector_default_when_unset() {
629 let _g = env_guard();
630 let state = Arc::new(Mutex::new(ReadFileState::new()));
631 with_env_var("RECURSIVE_REINJECT_FILES", None, || {
632 let r = build_file_reinjector_from_env(state.clone())
633 .expect("unset env must yield Some with defaults");
634 assert_eq!(r.max_files, 5);
635 });
636 }
637
638 #[test]
639 fn build_file_reinjector_custom_budget() {
640 let _g = env_guard();
641 let state = Arc::new(Mutex::new(ReadFileState::new()));
642 with_env_var("RECURSIVE_REINJECT_FILES", Some("2"), || {
643 with_env_var("RECURSIVE_REINJECT_FILE_BUDGET", Some("10000"), || {
644 let r = build_file_reinjector_from_env(state.clone())
645 .expect("explicit budget must yield Some");
646 assert_eq!(r.max_files, 2);
647 assert_eq!(r.token_budget, 10000);
648 });
649 });
650 }
651
652 use crate::skills::{Skill, SkillMode};
657
658 fn make_reinjector(skills: Vec<Skill>) -> SkillReinjector {
659 SkillReinjector {
660 token_budget: 25_000,
661 per_skill_budget: 5_000,
662 skills,
663 }
664 }
665
666 fn skill_tool_call(name: &str) -> crate::llm::ToolCall {
667 crate::llm::ToolCall {
668 id: "call_1".into(),
669 name: "Skill".into(),
670 arguments: serde_json::json!({"name": name}),
671 }
672 }
673
674 fn create_skill_on_disk(name: &str, body: &str) -> Skill {
675 let tmp = tempfile::TempDir::new().unwrap();
676 let skill_dir = tmp.path().join(name);
677 std::fs::create_dir(&skill_dir).unwrap();
678 let path = skill_dir.join("SKILL.md");
679 std::fs::write(
680 &path,
681 format!("---\nname: {name}\ndescription: {name} skill\n---\n\n{body}"),
682 )
683 .unwrap();
684 let _ = std::mem::ManuallyDrop::new(tmp);
687 Skill {
688 name: name.to_string(),
689 description: format!("{name} skill"),
690 path,
691 mode: SkillMode::Manual,
692 triggers: vec![],
693 hint: String::new(),
694 depends_on: vec![],
695 refs: vec![],
696 params: vec![],
697 scripts: vec![],
698 sections: vec![],
699 globs: None,
700 }
701 }
702
703 #[test]
704 fn skill_reinject_collects_loadskill_calls() {
705 let s1 = create_skill_on_disk("rust-trait", "Rust trait design patterns.");
706 let s2 = create_skill_on_disk("python-api", "Python API patterns.");
707 let reinjector = make_reinjector(vec![s1, s2]);
708
709 let pre_compact = vec![
710 Message::user("Do something".to_string()),
711 Message::assistant_with_tool_calls(
712 "Loading skill".to_string(),
713 vec![skill_tool_call("rust-trait")],
714 ),
715 Message::tool_result("call_1", "Rust trait skill body here"),
716 Message::user("Now another".to_string()),
717 Message::assistant_with_tool_calls(
718 "Loading skill".to_string(),
719 vec![skill_tool_call("python-api")],
720 ),
721 Message::tool_result("call_2", "Python API skill body here"),
722 ];
723
724 let msgs = reinjector.reinject(&pre_compact);
725 assert_eq!(msgs.len(), 2);
726 assert!(msgs[0].content.contains("python-api"));
728 assert!(msgs[0].content.contains("Python API patterns."));
729 assert!(msgs[1].content.contains("rust-trait"));
730 assert!(msgs[1].content.contains("Rust trait design patterns."));
731 }
732
733 #[test]
734 fn skill_reinject_dedups_repeated_invokes() {
735 let s1 = create_skill_on_disk("rust-trait", "Rust trait design patterns.");
736 let reinjector = make_reinjector(vec![s1]);
737
738 let pre_compact = vec![
739 Message::user("Do something".to_string()),
740 Message::assistant_with_tool_calls(
741 "Loading skill".to_string(),
742 vec![skill_tool_call("rust-trait")],
743 ),
744 Message::tool_result("call_1", "Rust trait body"),
745 Message::user("Again".to_string()),
746 Message::assistant_with_tool_calls(
747 "Loading skill again".to_string(),
748 vec![skill_tool_call("rust-trait")],
749 ),
750 Message::tool_result("call_2", "Rust trait body again"),
751 ];
752
753 let msgs = reinjector.reinject(&pre_compact);
754 assert_eq!(msgs.len(), 1, "same skill invoked twice → one attachment");
755 assert!(msgs[0].content.contains("rust-trait"));
756 }
757
758 #[test]
759 fn skill_reinject_skips_unknown_skill() {
760 let s1 = create_skill_on_disk("exists", "Exists skill.");
761 let reinjector = make_reinjector(vec![s1]);
762
763 let pre_compact = vec![
764 Message::user("Load missing skill".to_string()),
765 Message::assistant_with_tool_calls(
766 "Loading".to_string(),
767 vec![skill_tool_call("does-not-exist")],
768 ),
769 Message::tool_result("call_1", "Error: not found"),
770 ];
771
772 let msgs = reinjector.reinject(&pre_compact);
773 assert!(msgs.is_empty(), "unknown skill must be skipped, no panic");
774 }
775
776 #[test]
777 fn skill_reinject_truncates_oversized_skill() {
778 let huge_body = "X".repeat(100_000);
779 let s1 = create_skill_on_disk("huge-skill", &huge_body);
780 let reinjector = SkillReinjector {
782 token_budget: 50_000,
783 per_skill_budget: 5_000,
784 skills: vec![s1],
785 };
786
787 let pre_compact = vec![
788 Message::assistant_with_tool_calls(
789 "Loading".to_string(),
790 vec![skill_tool_call("huge-skill")],
791 ),
792 Message::tool_result("call_1", "Huge body"),
793 ];
794
795 let msgs = reinjector.reinject(&pre_compact);
796 assert_eq!(msgs.len(), 1);
797 let content = &msgs[0].content;
798 assert!(content.contains("[post-compact skill restore:"));
799 assert!(
800 content.contains("[... skill content truncated for compaction; use Read on the skill path if you need the full text]")
801 );
802 assert!(
803 content.len() < huge_body.len() + 100,
804 "truncated content should be shorter than original"
805 );
806 }
807
808 #[test]
809 fn skill_reinject_respects_total_budget() {
810 let s1 = create_skill_on_disk("skill-a", &"A".repeat(8_000));
811 let s2 = create_skill_on_disk("skill-b", &"B".repeat(8_000));
812 let s3 = create_skill_on_disk("skill-c", &"C".repeat(8_000));
813 let reinjector = SkillReinjector {
816 token_budget: 3_000,
817 per_skill_budget: 5_000,
818 skills: vec![s1, s2, s3],
819 };
820
821 let pre_compact = vec![
822 Message::assistant_with_tool_calls("first", vec![skill_tool_call("skill-a")]),
823 Message::tool_result("c1", "A"),
824 Message::assistant_with_tool_calls("second", vec![skill_tool_call("skill-b")]),
825 Message::tool_result("c2", "B"),
826 Message::assistant_with_tool_calls("third", vec![skill_tool_call("skill-c")]),
827 Message::tool_result("c3", "C"),
828 ];
829
830 let msgs = reinjector.reinject(&pre_compact);
831 assert_eq!(
833 msgs.len(),
834 1,
835 "budget 3000 should fit ~1 skill only (newest)"
836 );
837 assert!(
838 msgs[0].content.contains("skill-c"),
839 "newest-invoked skill (skill-c) should win under budget pressure"
840 );
841 }
842
843 #[test]
844 fn skill_reinject_empty_when_no_invokes() {
845 let s1 = create_skill_on_disk("rust-trait", "Rust trait design.");
846 let reinjector = make_reinjector(vec![s1]);
847
848 let pre_compact = vec![
849 Message::user("Hello".to_string()),
850 Message::assistant("No skill calls here.".to_string()),
851 Message::user("Right.".to_string()),
852 ];
853
854 let msgs = reinjector.reinject(&pre_compact);
855 assert!(msgs.is_empty(), "no skill calls → empty Vec");
856 }
857
858 #[test]
859 fn skill_reinject_handles_loadskill_alias() {
860 let s1 = create_skill_on_disk("my-skill", "My skill body.");
861 let reinjector = make_reinjector(vec![s1]);
862
863 let pre_compact = vec![Message::assistant_with_tool_calls(
865 "Loading".to_string(),
866 vec![crate::llm::ToolCall {
867 id: "c1".into(),
868 name: "LoadSkill".into(),
869 arguments: serde_json::json!({"name": "my-skill"}),
870 }],
871 )];
872
873 let msgs = reinjector.reinject(&pre_compact);
874 assert_eq!(msgs.len(), 1, "LoadSkill alias must be collected");
875 assert!(msgs[0].content.contains("my-skill"));
876 }
877
878 #[test]
879 fn skill_reinject_handles_loadskill_snake_alias() {
880 let s1 = create_skill_on_disk("my-skill", "My skill body.");
881 let reinjector = make_reinjector(vec![s1]);
882
883 let pre_compact = vec![Message::assistant_with_tool_calls(
885 "Loading".to_string(),
886 vec![crate::llm::ToolCall {
887 id: "c1".into(),
888 name: "load_skill".into(),
889 arguments: serde_json::json!({"name": "my-skill"}),
890 }],
891 )];
892
893 let msgs = reinjector.reinject(&pre_compact);
894 assert_eq!(msgs.len(), 1, "load_skill alias must be collected");
895 assert!(msgs[0].content.contains("my-skill"));
896 }
897
898 #[test]
899 fn build_skill_reinjector_from_env_disabled() {
900 let _g = env_guard();
901 with_env_var("RECURSIVE_REINJECT_SKILLS", Some("0"), || {
902 assert!(build_skill_reinjector_from_env(vec![]).is_none());
903 });
904 with_env_var("RECURSIVE_REINJECT_SKILLS", Some("off"), || {
905 assert!(build_skill_reinjector_from_env(vec![]).is_none());
906 });
907 with_env_var("RECURSIVE_REINJECT_SKILLS", Some("false"), || {
908 assert!(build_skill_reinjector_from_env(vec![]).is_none());
909 });
910 }
911
912 #[test]
913 fn build_skill_reinjector_from_env_unset() {
914 let _g = env_guard();
915 with_env_var("RECURSIVE_REINJECT_SKILLS", None, || {
916 let r = build_skill_reinjector_from_env(vec![]).expect("unset must yield Some");
917 assert_eq!(r.token_budget, 25_000);
918 });
919 }
920
921 #[test]
922 fn build_skill_reinjector_from_env_explicit_budget() {
923 let _g = env_guard();
924 with_env_var("RECURSIVE_REINJECT_SKILLS", Some("50000"), || {
925 let r =
926 build_skill_reinjector_from_env(vec![]).expect("explicit budget must yield Some");
927 assert_eq!(r.token_budget, 50000);
928 });
929 }
930
931 #[test]
932 fn build_skill_reinjector_from_env_secondary_budget() {
933 let _g = env_guard();
934 with_env_var("RECURSIVE_REINJECT_SKILLS", Some("1"), || {
935 let _r = build_skill_reinjector_from_env(vec![]).expect("truthy must yield Some");
939 });
942 }
943
944 #[test]
945 fn build_skill_reinjector_from_env_uses_secondary_budget_var() {
946 let _g = env_guard();
947 with_env_var("RECURSIVE_REINJECT_SKILLS", None, || {
948 with_env_var("RECURSIVE_REINJECT_SKILL_BUDGET", Some("10000"), || {
949 let r = build_skill_reinjector_from_env(vec![]).expect("budget var must be used");
950 assert_eq!(r.token_budget, 10000);
951 });
952 });
953 }
954
955 fn make_plan_todo_reinjector(todos: Vec<TodoItem>, plan: Option<&str>) -> PlanTodoReinjector {
960 let todo_list = Arc::new(RwLock::new(todos));
961 let gate = Arc::new(PlanApprovalGate::new());
962 if let Some(p) = plan {
963 gate.begin_approval(p.to_string());
964 }
965 PlanTodoReinjector::new(todo_list, gate)
966 }
967
968 #[test]
969 fn plan_todo_reinject_plan_when_pending() {
970 let r = make_plan_todo_reinjector(vec![], Some("Step 1: read files\nStep 2: edit"));
971 let msgs = r.reinject();
972 assert_eq!(msgs.len(), 1);
973 assert_eq!(msgs[0].role, crate::message::Role::System);
974 assert!(msgs[0].content.contains("[post-compact plan restore]"));
975 assert!(msgs[0].content.contains("You are in plan mode"));
976 assert!(msgs[0].content.contains("Step 1: read files"));
977 assert!(msgs[0].content.contains("Step 2: edit"));
978 }
979
980 #[test]
981 fn plan_todo_reinject_no_plan_when_none() {
982 let r = make_plan_todo_reinjector(vec![], None);
983 let msgs = r.reinject();
984 assert!(msgs.is_empty());
986 }
987
988 #[test]
989 fn plan_todo_reinject_todos_when_non_empty() {
990 let todos = vec![
991 TodoItem {
992 content: "Step 1".to_string(),
993 status: TodoStatus::Completed,
994 active_form: None,
995 },
996 TodoItem {
997 content: "Step 2".to_string(),
998 status: TodoStatus::Pending,
999 active_form: None,
1000 },
1001 TodoItem {
1002 content: "Step 3".to_string(),
1003 status: TodoStatus::InProgress,
1004 active_form: Some("Running step 3...".to_string()),
1005 },
1006 ];
1007 let r = make_plan_todo_reinjector(todos, None);
1008 let msgs = r.reinject();
1009 assert_eq!(msgs.len(), 1);
1010 assert_eq!(msgs[0].role, crate::message::Role::System);
1011 let content = &msgs[0].content;
1012 assert!(content.contains("[post-compact todo restore]"));
1013 assert!(content.contains("- [x] Step 1"));
1014 assert!(content.contains("- [ ] Step 2"));
1015 assert!(content.contains("- [/] Step 3"));
1016 assert!(content.contains("(active: Running step 3...)"));
1017 }
1018
1019 #[test]
1020 fn plan_todo_reinject_no_todos_when_empty() {
1021 let r = make_plan_todo_reinjector(vec![], Some("my plan"));
1022 let msgs = r.reinject();
1023 assert_eq!(msgs.len(), 1);
1024 assert!(msgs[0].content.contains("[post-compact plan restore]"));
1025 }
1027
1028 #[test]
1029 fn plan_todo_reinject_both_plan_and_todos() {
1030 let todos = vec![TodoItem {
1031 content: "Read files".to_string(),
1032 status: TodoStatus::Completed,
1033 active_form: None,
1034 }];
1035 let r = make_plan_todo_reinjector(todos, Some("my plan text"));
1036 let msgs = r.reinject();
1037 assert_eq!(msgs.len(), 2, "plan first, then todos");
1038 assert!(msgs[0].content.contains("[post-compact plan restore]"));
1040 assert!(msgs[1].content.contains("[post-compact todo restore]"));
1042 assert!(msgs[1].content.contains("- [x] Read files"));
1043 }
1044
1045 #[test]
1046 fn plan_todo_reinject_empty_when_neither() {
1047 let r = make_plan_todo_reinjector(vec![], None);
1048 let msgs = r.reinject();
1049 assert!(msgs.is_empty());
1050 }
1051}