Skip to main content

recursive/compact/
reinject.rs

1//! Post-compaction re-injection of recently-read files as System attachments.
2//!
3//! After an LLM-summary compaction, the transcript is `[summary, …recent N]`.
4//! The summary mentions file paths but not their contents, so the model on
5//! the next turn frequently re-`Read`s files it already read — paying for the
6//! read again and re-bloating the context.
7//!
8//! [`FileReinjector`] re-injects the most-recently-read files as
9//! [`Role::System`] attachments right after the summary, capped at
10//! `max_files` files, `token_budget` total tokens, and `per_file_budget`
11//! per file, deduplicated against the preserved tail.
12
13use 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/// Post-compaction re-injector for recently-read files.
22///
23/// Only emits [`Role::System`] messages — no [`Role::Tool`] messages, so
24/// no orphan tool result can be created (invariant #8 safe).
25#[derive(Debug, Clone)]
26pub struct FileReinjector {
27    /// Maximum number of files to re-inject (default 5).
28    pub max_files: usize,
29    /// Total token budget across all re-injected files (default 50_000).
30    /// Tokens are estimated as chars / 4.
31    pub token_budget: usize,
32    /// Per-file content budget in tokens (default 5_000).
33    pub per_file_budget: usize,
34    /// Shared read-file state — the same `Arc` used by Read/Edit/Write.
35    pub read_state: Arc<Mutex<ReadFileState>>,
36}
37
38impl FileReinjector {
39    /// Create a new `FileReinjector` with the given shared read state and
40    /// default limits (max_files=5, token_budget=50_000, per_file_budget=5_000).
41    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    /// Build attachment [`Message`]s to insert after the compaction summary.
51    ///
52    /// `preserved` is the recent-N slice kept verbatim — files whose path
53    /// already appears in a preserved [`Role::Tool`] message's content are
54    /// skipped (heuristic dedup).
55    ///
56    /// Returns one [`Message::system(…)`] per file. Returns an empty `Vec`
57    /// when no files qualify.
58    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(), // Poisoned lock — skip reinjection.
62        };
63
64        let recent = locked.recent_files(self.max_files);
65        // Drop the lock before processing content (no further state access needed).
66        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            // Heuristic dedup: skip if the path string appears in any
73            // preserved Tool message's content.
74            if path_appears_in_preserved(&path, preserved) {
75                continue;
76            }
77
78            // Truncate content to per_file_budget tokens (chars / 4).
79            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; // Conservative estimate (div ceiling).
91
92            // Check budget: skip this file if it would exceed the total.
93            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
110/// Heuristic dedup: return `true` if the given `path` string appears as a
111/// substring in any preserved [`Role::Tool`] message's content.
112///
113/// This may false-negative (path mentioned in non-Read output) but never
114/// false-positive-harmful (worst case: a file is re-injected that was
115/// already visible → minor redundancy, same as no dedup). A precise
116/// `tool_call_id → path` dedup is a follow-up.
117fn 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
128/// Build a [`FileReinjector`] from environment variables, returning `None`
129/// when file re-injection is disabled.
130///
131/// Env vars:
132/// - `RECURSIVE_REINJECT_FILES` — `0`/`off`/`false` = disabled → `None`;
133///   unset = default 5; positive integer = explicit count.
134/// - `RECURSIVE_REINJECT_FILE_BUDGET` — unset = 50_000; positive integer =
135///   explicit token budget.
136/// - per_file_budget is fixed at 5_000 (not env-tunable in v1).
137///
138/// Uses the given `read_state` (shared with Read/Edit/Write tools).
139pub 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), // Default.
147    };
148    let max_files = max_files?; // None = disabled.
149
150    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// ═════════════════════════════════════════════════════════════════════════
165// SkillReinjector
166// ═════════════════════════════════════════════════════════════════════════
167
168/// Post-compaction re-injector for invoked skills.
169///
170/// When a `Skill` (`LoadSkill`) tool was called mid-session, its body lived
171/// in the transcript as part of the tool result. After an LLM-summary
172/// compaction the body is gone (folded into the summary's prose), so the
173/// model loses the skill's operating instructions and may stop following
174/// them. This reinjector recovers the invoked set by scanning the
175/// pre-compaction transcript for `Skill`/`LoadSkill`/`load_skill` tool
176/// calls, looks up bodies from the discovered `Vec<Skill>`, and emits
177/// [`Role::System`] attachment messages.
178///
179/// Only emits [`Role::System`] messages — no [`Role::Tool`] messages, so
180/// no orphan tool result can be created (invariant #8 safe).
181#[derive(Debug, Clone)]
182pub struct SkillReinjector {
183    /// Total token budget across all re-injected skills (default 25_000).
184    /// Tokens are estimated as chars / 4.
185    pub token_budget: usize,
186    /// Per-skill content budget in tokens (default 5_000).
187    pub per_skill_budget: usize,
188    /// Discovered skill catalog to look up bodies.
189    pub skills: Vec<crate::skills::Skill>,
190}
191
192impl SkillReinjector {
193    /// Create a new [`SkillReinjector`] with the given skill catalog and
194    /// default limits (token_budget=25_000, per_skill_budget=5_000).
195    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    /// Scan `pre_compact` for `Skill`/`LoadSkill`/`load_skill` tool calls,
204    /// collect distinct skill names in invocation order, look up each in
205    /// `self.skills`, and emit [`Role::System`] attachment messages
206    /// (head-truncated to budget).
207    ///
208    /// Returns an empty `Vec` when no invoked skills are found or the
209    /// catalog is empty.
210    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        // Phase 1: collect distinct skill names in first-invocation order.
221        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                    // Parse the skill name from the `name` argument.
236                    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        // Phase 2: look up each skill, get its body, head-truncate.
253        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            // Head-truncate to per_skill_budget * 4 chars.
271            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        // Phase 3: reverse order (newest-invoked first) and apply total budget.
293        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
314/// Build a [`SkillReinjector`] from environment variables, returning `None`
315/// when skill re-injection is disabled.
316///
317/// Env vars:
318/// - `RECURSIVE_REINJECT_SKILLS` — `0`/`off`/`false` = disabled → `None`;
319///   unset = enabled with default budget (25_000); positive integer =
320///   explicit token budget override.
321/// - `RECURSIVE_REINJECT_SKILL_BUDGET` — unset = 25_000; positive integer =
322///   explicit token budget.
323/// - per_skill_budget is fixed at 5_000 (not env-tunable in v1).
324///
325/// Uses the given discovered skills list (same catalog used by the
326/// `LoadSkill` tool and `assemble_system_prompt`).
327pub 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// ═════════════════════════════════════════════════════════════════════════
359// PlanTodoReinjector
360// ═════════════════════════════════════════════════════════════════════════
361
362/// Post-compaction re-injector for the pending plan and task list.
363///
364/// After an LLM-summary compaction, the plan text and todo checklist are
365/// folded into prose or dropped entirely — the model loses track of the
366/// pending plan and outstanding tasks, and may re-propose a plan or forget
367/// about pending checklist items. This reinjector recovers both from the
368/// shared runtime state and emits [`Role::System`] attachment messages
369/// (plan first, then todos).
370///
371/// Only emits [`Role::System`] messages — no [`Role::Tool`] messages, so
372/// no orphan tool result can be created (invariant #8 safe).
373#[derive(Debug, Clone)]
374pub struct PlanTodoReinjector {
375    /// Shared task list (same `Arc` used by `TodoWriteTool`).
376    pub todos: Arc<RwLock<Vec<TodoItem>>>,
377    /// Shared plan-approval gate (same `Arc` used by `EnterPlanModeTool` /
378    /// `ExitPlanModeTool`).
379    pub plan_gate: Arc<PlanApprovalGate>,
380}
381
382impl PlanTodoReinjector {
383    /// Create a new [`PlanTodoReinjector`].
384    pub fn new(todos: Arc<RwLock<Vec<TodoItem>>>, plan_gate: Arc<PlanApprovalGate>) -> Self {
385        Self { todos, plan_gate }
386    }
387
388    /// Return 0-2 System attachment messages: the pending plan (if any) and
389    /// the current todo list (if non-empty). Empty Vec if neither applies.
390    ///
391    /// Plan goes first (so the model reads the plan before the task list).
392    pub fn reinject(&self) -> Vec<Message> {
393        let mut result: Vec<Message> = Vec::new();
394
395        // 1. Pending plan.
396        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                // No pending plan — skip.
408            }
409        }
410
411        // 2. Current todo list (if non-empty).
412        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    // Env-reading tests share a process-global variable and must not run
449    // concurrently (one's set_var races another's read). This guard
450    // serializes every test that touches RECURSIVE_REINJECT_FILES. The G334
451    // spec calls this out: "one sequential test".
452    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    // ── reinject_returns_recent_files_as_system_messages ──────────────────
468
469    #[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    // ── reinject_respects_max_files ───────────────────────────────────────
482
483    #[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        // Most recent first: c, b
493        assert!(msgs[0].content.contains("c.rs"));
494        assert!(msgs[1].content.contains("b.rs"));
495    }
496
497    // ── reinject_respects_token_budget ────────────────────────────────────
498
499    #[test]
500    fn reinject_respects_token_budget() {
501        // Two large files; budget fits only one.
502        // 9000 chars → ~2251 tokens (chars/4 + 1). Budget 2500 fits one but
503        // not two (2251 + 2251 > 2500).
504        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    // ── reinject_truncates_oversized_file ─────────────────────────────────
520
521    #[test]
522    fn reinject_truncates_oversized_file() {
523        // Content much larger than per_file_budget (5K tokens = 20K chars).
524        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        // Should be truncated: per_file_budget=5000 tokens ≈ 20000 chars + truncation marker.
535        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    // ── reinject_dedups_against_preserved_tail ────────────────────────────
544
545    #[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    // ── reinject_empty_when_no_files ──────────────────────────────────────
558
559    #[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    // ── build_file_reinjector_from_env ────────────────────────────────────
571
572    /// Helper to run a function with a temporary env var set.
573    /// Acquires the global env_lock via PinnedRecursiveHome to serialise
574    /// env-mutating tests.
575    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    // ═════════════════════════════════════════════════════════════════════════
653    // SkillReinjector tests
654    // ═════════════════════════════════════════════════════════════════════════
655
656    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        // Leak the TempDir so the files survive for the test duration.
685        // The OS will clean them up on next reboot — acceptable for tests.
686        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        // Newest-invoked first: python-api was invoked second (most recent).
727        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        // per_skill_budget = 5000 tokens = 20000 chars
781        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        // Each att ≈ 8000 + overhead chars → ~2000+ tokens.
814        // Budget of 3000 tokens fits only the newest 1 skill (newest first).
815        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        // Newest first: skill-c is the most recent invocation.
832        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        // Use `LoadSkill` as the tool call name
864        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        // Use `load_skill` as the tool call name
884        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            // RECURSIVE_REINJECT_SKILLS = "1" means the feature is enabled
936            // with default budget (the value is not parsed as a budget
937            // when it's a truthy non-integer).
938            let _r = build_skill_reinjector_from_env(vec![]).expect("truthy must yield Some");
939            // "1" is parsed as an explicit budget (= 1 token), which is valid.
940            // We just verify it doesn't return None.
941        });
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    // ═════════════════════════════════════════════════════════════════════════
956    // PlanTodoReinjector tests
957    // ═════════════════════════════════════════════════════════════════════════
958
959    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        // No plan, no todos → empty vec
985        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        // Only plan message, no todo message
1026    }
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        // First message: plan
1039        assert!(msgs[0].content.contains("[post-compact plan restore]"));
1040        // Second message: todos
1041        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}