use std::path::Path;
use std::sync::{Arc, Mutex, RwLock};
use crate::message::Message;
use crate::tools::fs::ReadFileState;
use crate::tools::plan_mode::PlanApprovalGate;
use crate::tools::todo::{TodoItem, TodoStatus};
#[derive(Debug, Clone)]
pub struct FileReinjector {
pub max_files: usize,
pub token_budget: usize,
pub per_file_budget: usize,
pub read_state: Arc<Mutex<ReadFileState>>,
}
impl FileReinjector {
pub fn new(read_state: Arc<Mutex<ReadFileState>>) -> Self {
Self {
max_files: 5,
token_budget: 50_000,
per_file_budget: 5_000,
read_state,
}
}
pub fn reinject(&self, preserved: &[Message]) -> Vec<Message> {
let locked = match self.read_state.lock() {
Ok(s) => s,
Err(_) => return Vec::new(), };
let recent = locked.recent_files(self.max_files);
drop(locked);
let mut result: Vec<Message> = Vec::new();
let mut tokens_used: usize = 0;
for (path, content) in recent {
if path_appears_in_preserved(&path, preserved) {
continue;
}
let max_chars = self.per_file_budget * 4;
let truncated = if content.len() > max_chars {
format!(
"{}...\n[... truncated for compaction; use Read on the path for full text]",
&content[..max_chars]
)
} else {
content
};
let att_len_chars = truncated.len();
let att_tokens = att_len_chars / 4 + 1;
if tokens_used + att_tokens > self.token_budget {
continue;
}
tokens_used += att_tokens;
let msg_text = format!(
"[post-compact file restore: {}]\n{}",
path.display(),
truncated
);
result.push(Message::system(msg_text));
}
result
}
}
fn path_appears_in_preserved(path: &Path, preserved: &[Message]) -> bool {
let path_str = path.to_string_lossy();
preserved.iter().any(|m| {
if m.role == crate::message::Role::Tool {
m.content.contains(path_str.as_ref())
} else {
false
}
})
}
pub fn build_file_reinjector_from_env(
read_state: Arc<Mutex<ReadFileState>>,
) -> Option<FileReinjector> {
let raw = std::env::var("RECURSIVE_REINJECT_FILES").ok();
let max_files: Option<usize> = match raw.as_deref() {
Some("0") | Some("off") | Some("false") => None,
Some(s) => s.parse::<usize>().ok().filter(|&n| n > 0),
None => Some(5), };
let max_files = max_files?;
let budget_raw = std::env::var("RECURSIVE_REINJECT_FILE_BUDGET").ok();
let token_budget: usize = match budget_raw.as_deref() {
Some(s) => s.parse::<usize>().ok().filter(|&n| n > 0).unwrap_or(50_000),
None => 50_000,
};
Some(FileReinjector {
max_files,
token_budget,
per_file_budget: 5_000,
read_state,
})
}
#[derive(Debug, Clone)]
pub struct SkillReinjector {
pub token_budget: usize,
pub per_skill_budget: usize,
pub skills: Vec<crate::skills::Skill>,
}
impl SkillReinjector {
pub fn new(skills: Vec<crate::skills::Skill>) -> Self {
Self {
token_budget: 25_000,
per_skill_budget: 5_000,
skills,
}
}
pub fn reinject(
&self,
pre_compact: &[crate::message::Message],
) -> Vec<crate::message::Message> {
use std::collections::HashSet;
if self.skills.is_empty() {
return Vec::new();
}
let skill_names: Vec<String> = {
let mut seen: HashSet<String> = HashSet::new();
let mut ordered: Vec<String> = Vec::new();
for msg in pre_compact {
if msg.role != crate::message::Role::Assistant {
continue;
}
for tc in &msg.tool_calls {
let is_skill_call =
tc.name == "Skill" || tc.name == "LoadSkill" || tc.name == "load_skill";
if !is_skill_call {
continue;
}
let skill_name = match tc.arguments.get("name").and_then(|v| v.as_str()) {
Some(n) => n.to_lowercase(),
None => continue,
};
if seen.insert(skill_name.clone()) {
ordered.push(skill_name);
}
}
}
ordered
};
if skill_names.is_empty() {
return Vec::new();
}
let mut candidates: Vec<(String, String, String)> = Vec::new();
for name_lower in &skill_names {
let skill = match self
.skills
.iter()
.find(|s| s.name.to_lowercase() == *name_lower)
{
Some(s) => s,
None => continue,
};
let content = match std::fs::read_to_string(&skill.path) {
Ok(c) => c,
Err(_) => continue,
};
let body = crate::skills::extract_skill_body(&content).to_string();
let max_chars = self.per_skill_budget.saturating_mul(4);
let body = if body.len() > max_chars {
format!(
"{}...\n\n[... skill content truncated for compaction; use Read on the skill path if you need the full text]",
&body[..max_chars]
)
} else {
body
};
candidates.push((
skill.name.clone(),
skill.path.to_string_lossy().to_string(),
body,
));
}
if candidates.is_empty() {
return Vec::new();
}
candidates.reverse();
let mut result: Vec<crate::message::Message> = Vec::new();
let mut tokens_used: usize = 0;
for (name, path, body) in candidates {
let att_text = format!("[post-compact skill restore: {name} @ {path}]\n{body}");
let att_chars = att_text.len();
let att_tokens = att_chars / 4 + 1;
if tokens_used + att_tokens > self.token_budget {
continue;
}
tokens_used += att_tokens;
result.push(crate::message::Message::system(att_text));
}
result
}
}
pub fn build_skill_reinjector_from_env(
skills: Vec<crate::skills::Skill>,
) -> Option<SkillReinjector> {
let raw = std::env::var("RECURSIVE_REINJECT_SKILLS").ok();
match raw.as_deref() {
Some("0") | Some("off") | Some("false") => return None,
_ => {}
}
let token_budget: usize = match raw.as_deref() {
Some(s) => s.parse::<usize>().ok().filter(|&n| n > 0).unwrap_or(25_000),
None => {
let budget_raw = std::env::var("RECURSIVE_REINJECT_SKILL_BUDGET").ok();
match budget_raw.as_deref() {
Some(s) => s.parse::<usize>().ok().filter(|&n| n > 0).unwrap_or(25_000),
None => 25_000,
}
}
};
if token_budget == 0 {
return None;
}
Some(SkillReinjector {
token_budget,
per_skill_budget: 5_000,
skills,
})
}
#[derive(Debug, Clone)]
pub struct PlanTodoReinjector {
pub todos: Arc<RwLock<Vec<TodoItem>>>,
pub plan_gate: Arc<PlanApprovalGate>,
}
impl PlanTodoReinjector {
pub fn new(todos: Arc<RwLock<Vec<TodoItem>>>, plan_gate: Arc<PlanApprovalGate>) -> Self {
Self { todos, plan_gate }
}
pub fn reinject(&self) -> Vec<Message> {
let mut result: Vec<Message> = Vec::new();
match self.plan_gate.pending_plan() {
Some(plan) => {
let msg = format!(
"[post-compact plan restore]\n\
You are in plan mode. The pending plan (awaiting approval) is:\n\
{}",
plan
);
result.push(Message::system(msg));
}
None => {
}
}
let todo_items: Vec<TodoItem> = match self.todos.read() {
Ok(guard) => guard.clone(),
Err(_) => {
tracing::warn!("PlanTodoReinjector: todo list lock poisoned, skipping todos");
Vec::new()
}
};
if !todo_items.is_empty() {
let mut list_body = String::from("[post-compact todo restore]\nCurrent task list:\n");
for item in &todo_items {
let checkbox = match item.status {
TodoStatus::Completed => "[x]",
TodoStatus::Cancelled => "[~]",
TodoStatus::Pending => "[ ]",
TodoStatus::InProgress => "[/]",
};
list_body.push_str(&format!("- {} {}\n", checkbox, item.content));
if item.status == TodoStatus::InProgress {
if let Some(ref active) = item.active_form {
list_body.push_str(&format!(" (active: {})\n", active));
}
}
}
result.push(Message::system(list_body));
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner())
}
fn make_state_with(files: Vec<(&str, &str)>) -> Arc<Mutex<ReadFileState>> {
let state = Arc::new(Mutex::new(ReadFileState::new()));
let mut locked = state.lock().unwrap();
for (path, content) in files {
locked.record(PathBuf::from(path), false, content.to_string(), 1000);
}
drop(locked);
state
}
#[test]
fn reinject_returns_recent_files_as_system_messages() {
let state = make_state_with(vec![("src/lib.rs", "pub fn foo() {}")]);
let r = FileReinjector::new(state);
let msgs = r.reinject(&[]);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, crate::message::Role::System);
assert!(msgs[0].content.contains("src/lib.rs"));
assert!(msgs[0].content.contains("pub fn foo() {}"));
assert!(msgs[0].content.starts_with("[post-compact file restore:"));
}
#[test]
fn reinject_respects_max_files() {
let state = make_state_with(vec![("a.rs", "a"), ("b.rs", "b"), ("c.rs", "c")]);
let r = FileReinjector {
max_files: 2,
..FileReinjector::new(state)
};
let msgs = r.reinject(&[]);
assert_eq!(msgs.len(), 2);
assert!(msgs[0].content.contains("c.rs"));
assert!(msgs[1].content.contains("b.rs"));
}
#[test]
fn reinject_respects_token_budget() {
let big = "A".repeat(9_000);
let state = make_state_with(vec![("big1.rs", &big), ("big2.rs", &big)]);
let r = FileReinjector {
token_budget: 2500,
per_file_budget: 5000,
..FileReinjector::new(state)
};
let msgs = r.reinject(&[]);
assert_eq!(
msgs.len(),
1,
"budget 2500 should fit exactly one ~2251-token file"
);
}
#[test]
fn reinject_truncates_oversized_file() {
let huge = "X".repeat(100_000);
let state = make_state_with(vec![("huge.rs", &huge)]);
let r = FileReinjector::new(state);
let msgs = r.reinject(&[]);
assert_eq!(msgs.len(), 1);
let content = &msgs[0].content;
assert!(content.contains("[post-compact file restore:"));
assert!(
content.contains("[... truncated for compaction; use Read on the path for full text]")
);
assert!(
content.len() < huge.len() + 150,
"truncated content should be shorter than original: {} < {}",
content.len(),
huge.len()
);
}
#[test]
fn reinject_dedups_against_preserved_tail() {
let state = make_state_with(vec![("src/lib.rs", "pub fn foo() {}")]);
let preserved = vec![Message::tool_result("c1", "src/lib.rs was read and ...")];
let r = FileReinjector::new(state);
let msgs = r.reinject(&preserved);
assert!(
msgs.is_empty(),
"file whose path appears in a preserved Tool message must be skipped"
);
}
#[test]
fn reinject_empty_when_no_files() {
let state = Arc::new(Mutex::new(ReadFileState::new()));
let r = FileReinjector::new(state);
let msgs = r.reinject(&[]);
assert!(
msgs.is_empty(),
"empty ReadFileState must produce empty Vec"
);
}
fn with_env_var(name: &str, value: Option<&str>, f: impl FnOnce()) {
let prev = std::env::var(name).ok();
match value {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
f();
match prev {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
#[test]
fn build_file_reinjector_disabled_when_env_zero() {
let _g = env_guard();
let state = Arc::new(Mutex::new(ReadFileState::new()));
with_env_var("RECURSIVE_REINJECT_FILES", Some("0"), || {
assert!(build_file_reinjector_from_env(state.clone()).is_none());
});
}
#[test]
fn build_file_reinjector_disabled_when_env_off() {
let _g = env_guard();
let state = Arc::new(Mutex::new(ReadFileState::new()));
with_env_var("RECURSIVE_REINJECT_FILES", Some("off"), || {
assert!(build_file_reinjector_from_env(state.clone()).is_none());
});
}
#[test]
fn build_file_reinjector_disabled_when_env_false() {
let _g = env_guard();
let state = Arc::new(Mutex::new(ReadFileState::new()));
with_env_var("RECURSIVE_REINJECT_FILES", Some("false"), || {
assert!(build_file_reinjector_from_env(state.clone()).is_none());
});
}
#[test]
fn build_file_reinjector_explicit_count() {
let _g = env_guard();
let state = Arc::new(Mutex::new(ReadFileState::new()));
with_env_var("RECURSIVE_REINJECT_FILES", Some("3"), || {
let r = build_file_reinjector_from_env(state.clone())
.expect("explicit positive count must yield Some");
assert_eq!(r.max_files, 3);
assert_eq!(r.token_budget, 50_000);
});
}
#[test]
fn build_file_reinjector_default_when_unset() {
let _g = env_guard();
let state = Arc::new(Mutex::new(ReadFileState::new()));
with_env_var("RECURSIVE_REINJECT_FILES", None, || {
let r = build_file_reinjector_from_env(state.clone())
.expect("unset env must yield Some with defaults");
assert_eq!(r.max_files, 5);
});
}
#[test]
fn build_file_reinjector_custom_budget() {
let _g = env_guard();
let state = Arc::new(Mutex::new(ReadFileState::new()));
with_env_var("RECURSIVE_REINJECT_FILES", Some("2"), || {
with_env_var("RECURSIVE_REINJECT_FILE_BUDGET", Some("10000"), || {
let r = build_file_reinjector_from_env(state.clone())
.expect("explicit budget must yield Some");
assert_eq!(r.max_files, 2);
assert_eq!(r.token_budget, 10000);
});
});
}
use crate::skills::{Skill, SkillMode};
fn make_reinjector(skills: Vec<Skill>) -> SkillReinjector {
SkillReinjector {
token_budget: 25_000,
per_skill_budget: 5_000,
skills,
}
}
fn skill_tool_call(name: &str) -> crate::llm::ToolCall {
crate::llm::ToolCall {
id: "call_1".into(),
name: "Skill".into(),
arguments: serde_json::json!({"name": name}),
}
}
fn create_skill_on_disk(name: &str, body: &str) -> Skill {
let tmp = tempfile::TempDir::new().unwrap();
let skill_dir = tmp.path().join(name);
std::fs::create_dir(&skill_dir).unwrap();
let path = skill_dir.join("SKILL.md");
std::fs::write(
&path,
format!("---\nname: {name}\ndescription: {name} skill\n---\n\n{body}"),
)
.unwrap();
let _ = std::mem::ManuallyDrop::new(tmp);
Skill {
name: name.to_string(),
description: format!("{name} skill"),
path,
mode: SkillMode::Manual,
triggers: vec![],
hint: String::new(),
depends_on: vec![],
refs: vec![],
params: vec![],
scripts: vec![],
sections: vec![],
globs: None,
}
}
#[test]
fn skill_reinject_collects_loadskill_calls() {
let s1 = create_skill_on_disk("rust-trait", "Rust trait design patterns.");
let s2 = create_skill_on_disk("python-api", "Python API patterns.");
let reinjector = make_reinjector(vec![s1, s2]);
let pre_compact = vec![
Message::user("Do something".to_string()),
Message::assistant_with_tool_calls(
"Loading skill".to_string(),
vec![skill_tool_call("rust-trait")],
),
Message::tool_result("call_1", "Rust trait skill body here"),
Message::user("Now another".to_string()),
Message::assistant_with_tool_calls(
"Loading skill".to_string(),
vec![skill_tool_call("python-api")],
),
Message::tool_result("call_2", "Python API skill body here"),
];
let msgs = reinjector.reinject(&pre_compact);
assert_eq!(msgs.len(), 2);
assert!(msgs[0].content.contains("python-api"));
assert!(msgs[0].content.contains("Python API patterns."));
assert!(msgs[1].content.contains("rust-trait"));
assert!(msgs[1].content.contains("Rust trait design patterns."));
}
#[test]
fn skill_reinject_dedups_repeated_invokes() {
let s1 = create_skill_on_disk("rust-trait", "Rust trait design patterns.");
let reinjector = make_reinjector(vec![s1]);
let pre_compact = vec![
Message::user("Do something".to_string()),
Message::assistant_with_tool_calls(
"Loading skill".to_string(),
vec![skill_tool_call("rust-trait")],
),
Message::tool_result("call_1", "Rust trait body"),
Message::user("Again".to_string()),
Message::assistant_with_tool_calls(
"Loading skill again".to_string(),
vec![skill_tool_call("rust-trait")],
),
Message::tool_result("call_2", "Rust trait body again"),
];
let msgs = reinjector.reinject(&pre_compact);
assert_eq!(msgs.len(), 1, "same skill invoked twice → one attachment");
assert!(msgs[0].content.contains("rust-trait"));
}
#[test]
fn skill_reinject_skips_unknown_skill() {
let s1 = create_skill_on_disk("exists", "Exists skill.");
let reinjector = make_reinjector(vec![s1]);
let pre_compact = vec![
Message::user("Load missing skill".to_string()),
Message::assistant_with_tool_calls(
"Loading".to_string(),
vec![skill_tool_call("does-not-exist")],
),
Message::tool_result("call_1", "Error: not found"),
];
let msgs = reinjector.reinject(&pre_compact);
assert!(msgs.is_empty(), "unknown skill must be skipped, no panic");
}
#[test]
fn skill_reinject_truncates_oversized_skill() {
let huge_body = "X".repeat(100_000);
let s1 = create_skill_on_disk("huge-skill", &huge_body);
let reinjector = SkillReinjector {
token_budget: 50_000,
per_skill_budget: 5_000,
skills: vec![s1],
};
let pre_compact = vec![
Message::assistant_with_tool_calls(
"Loading".to_string(),
vec![skill_tool_call("huge-skill")],
),
Message::tool_result("call_1", "Huge body"),
];
let msgs = reinjector.reinject(&pre_compact);
assert_eq!(msgs.len(), 1);
let content = &msgs[0].content;
assert!(content.contains("[post-compact skill restore:"));
assert!(
content.contains("[... skill content truncated for compaction; use Read on the skill path if you need the full text]")
);
assert!(
content.len() < huge_body.len() + 100,
"truncated content should be shorter than original"
);
}
#[test]
fn skill_reinject_respects_total_budget() {
let s1 = create_skill_on_disk("skill-a", &"A".repeat(8_000));
let s2 = create_skill_on_disk("skill-b", &"B".repeat(8_000));
let s3 = create_skill_on_disk("skill-c", &"C".repeat(8_000));
let reinjector = SkillReinjector {
token_budget: 3_000,
per_skill_budget: 5_000,
skills: vec![s1, s2, s3],
};
let pre_compact = vec![
Message::assistant_with_tool_calls("first", vec![skill_tool_call("skill-a")]),
Message::tool_result("c1", "A"),
Message::assistant_with_tool_calls("second", vec![skill_tool_call("skill-b")]),
Message::tool_result("c2", "B"),
Message::assistant_with_tool_calls("third", vec![skill_tool_call("skill-c")]),
Message::tool_result("c3", "C"),
];
let msgs = reinjector.reinject(&pre_compact);
assert_eq!(
msgs.len(),
1,
"budget 3000 should fit ~1 skill only (newest)"
);
assert!(
msgs[0].content.contains("skill-c"),
"newest-invoked skill (skill-c) should win under budget pressure"
);
}
#[test]
fn skill_reinject_empty_when_no_invokes() {
let s1 = create_skill_on_disk("rust-trait", "Rust trait design.");
let reinjector = make_reinjector(vec![s1]);
let pre_compact = vec![
Message::user("Hello".to_string()),
Message::assistant("No skill calls here.".to_string()),
Message::user("Right.".to_string()),
];
let msgs = reinjector.reinject(&pre_compact);
assert!(msgs.is_empty(), "no skill calls → empty Vec");
}
#[test]
fn skill_reinject_handles_loadskill_alias() {
let s1 = create_skill_on_disk("my-skill", "My skill body.");
let reinjector = make_reinjector(vec![s1]);
let pre_compact = vec![Message::assistant_with_tool_calls(
"Loading".to_string(),
vec![crate::llm::ToolCall {
id: "c1".into(),
name: "LoadSkill".into(),
arguments: serde_json::json!({"name": "my-skill"}),
}],
)];
let msgs = reinjector.reinject(&pre_compact);
assert_eq!(msgs.len(), 1, "LoadSkill alias must be collected");
assert!(msgs[0].content.contains("my-skill"));
}
#[test]
fn skill_reinject_handles_loadskill_snake_alias() {
let s1 = create_skill_on_disk("my-skill", "My skill body.");
let reinjector = make_reinjector(vec![s1]);
let pre_compact = vec![Message::assistant_with_tool_calls(
"Loading".to_string(),
vec![crate::llm::ToolCall {
id: "c1".into(),
name: "load_skill".into(),
arguments: serde_json::json!({"name": "my-skill"}),
}],
)];
let msgs = reinjector.reinject(&pre_compact);
assert_eq!(msgs.len(), 1, "load_skill alias must be collected");
assert!(msgs[0].content.contains("my-skill"));
}
#[test]
fn build_skill_reinjector_from_env_disabled() {
let _g = env_guard();
with_env_var("RECURSIVE_REINJECT_SKILLS", Some("0"), || {
assert!(build_skill_reinjector_from_env(vec![]).is_none());
});
with_env_var("RECURSIVE_REINJECT_SKILLS", Some("off"), || {
assert!(build_skill_reinjector_from_env(vec![]).is_none());
});
with_env_var("RECURSIVE_REINJECT_SKILLS", Some("false"), || {
assert!(build_skill_reinjector_from_env(vec![]).is_none());
});
}
#[test]
fn build_skill_reinjector_from_env_unset() {
let _g = env_guard();
with_env_var("RECURSIVE_REINJECT_SKILLS", None, || {
let r = build_skill_reinjector_from_env(vec![]).expect("unset must yield Some");
assert_eq!(r.token_budget, 25_000);
});
}
#[test]
fn build_skill_reinjector_from_env_explicit_budget() {
let _g = env_guard();
with_env_var("RECURSIVE_REINJECT_SKILLS", Some("50000"), || {
let r =
build_skill_reinjector_from_env(vec![]).expect("explicit budget must yield Some");
assert_eq!(r.token_budget, 50000);
});
}
#[test]
fn build_skill_reinjector_from_env_secondary_budget() {
let _g = env_guard();
with_env_var("RECURSIVE_REINJECT_SKILLS", Some("1"), || {
let _r = build_skill_reinjector_from_env(vec![]).expect("truthy must yield Some");
});
}
#[test]
fn build_skill_reinjector_from_env_uses_secondary_budget_var() {
let _g = env_guard();
with_env_var("RECURSIVE_REINJECT_SKILLS", None, || {
with_env_var("RECURSIVE_REINJECT_SKILL_BUDGET", Some("10000"), || {
let r = build_skill_reinjector_from_env(vec![]).expect("budget var must be used");
assert_eq!(r.token_budget, 10000);
});
});
}
fn make_plan_todo_reinjector(todos: Vec<TodoItem>, plan: Option<&str>) -> PlanTodoReinjector {
let todo_list = Arc::new(RwLock::new(todos));
let gate = Arc::new(PlanApprovalGate::new());
if let Some(p) = plan {
gate.begin_approval(p.to_string());
}
PlanTodoReinjector::new(todo_list, gate)
}
#[test]
fn plan_todo_reinject_plan_when_pending() {
let r = make_plan_todo_reinjector(vec![], Some("Step 1: read files\nStep 2: edit"));
let msgs = r.reinject();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, crate::message::Role::System);
assert!(msgs[0].content.contains("[post-compact plan restore]"));
assert!(msgs[0].content.contains("You are in plan mode"));
assert!(msgs[0].content.contains("Step 1: read files"));
assert!(msgs[0].content.contains("Step 2: edit"));
}
#[test]
fn plan_todo_reinject_no_plan_when_none() {
let r = make_plan_todo_reinjector(vec![], None);
let msgs = r.reinject();
assert!(msgs.is_empty());
}
#[test]
fn plan_todo_reinject_todos_when_non_empty() {
let todos = vec![
TodoItem {
content: "Step 1".to_string(),
status: TodoStatus::Completed,
active_form: None,
},
TodoItem {
content: "Step 2".to_string(),
status: TodoStatus::Pending,
active_form: None,
},
TodoItem {
content: "Step 3".to_string(),
status: TodoStatus::InProgress,
active_form: Some("Running step 3...".to_string()),
},
];
let r = make_plan_todo_reinjector(todos, None);
let msgs = r.reinject();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, crate::message::Role::System);
let content = &msgs[0].content;
assert!(content.contains("[post-compact todo restore]"));
assert!(content.contains("- [x] Step 1"));
assert!(content.contains("- [ ] Step 2"));
assert!(content.contains("- [/] Step 3"));
assert!(content.contains("(active: Running step 3...)"));
}
#[test]
fn plan_todo_reinject_no_todos_when_empty() {
let r = make_plan_todo_reinjector(vec![], Some("my plan"));
let msgs = r.reinject();
assert_eq!(msgs.len(), 1);
assert!(msgs[0].content.contains("[post-compact plan restore]"));
}
#[test]
fn plan_todo_reinject_both_plan_and_todos() {
let todos = vec![TodoItem {
content: "Read files".to_string(),
status: TodoStatus::Completed,
active_form: None,
}];
let r = make_plan_todo_reinjector(todos, Some("my plan text"));
let msgs = r.reinject();
assert_eq!(msgs.len(), 2, "plan first, then todos");
assert!(msgs[0].content.contains("[post-compact plan restore]"));
assert!(msgs[1].content.contains("[post-compact todo restore]"));
assert!(msgs[1].content.contains("- [x] Read files"));
}
#[test]
fn plan_todo_reinject_empty_when_neither() {
let r = make_plan_todo_reinjector(vec![], None);
let msgs = r.reinject();
assert!(msgs.is_empty());
}
}