use crate::tools::TodoStateProvider;
use crate::tools::todo::{TodoPhase, TodoStatus};
pub struct ActiveReminderInputs<'a> {
pub todos: Option<&'a dyn TodoStateProvider>,
#[allow(dead_code)]
pub subagent_state: Option<()>,
#[allow(dead_code)]
pub background_tasks: Option<()>,
#[allow(dead_code)]
pub last_n_assistant_turns: usize,
}
impl<'a> Default for ActiveReminderInputs<'a> {
fn default() -> Self {
Self {
todos: None,
subagent_state: None,
background_tasks: None,
last_n_assistant_turns: 0,
}
}
}
impl<'a> Clone for ActiveReminderInputs<'a> {
fn clone(&self) -> Self {
Self {
todos: self.todos,
subagent_state: self.subagent_state,
background_tasks: self.background_tasks,
last_n_assistant_turns: self.last_n_assistant_turns,
}
}
}
impl<'a> std::fmt::Debug for ActiveReminderInputs<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ActiveReminderInputs")
.field("todos", &self.todos.map(|p| p.get_phases()))
.field("subagent_state", &self.subagent_state)
.field("background_tasks", &self.background_tasks)
.field("last_n_assistant_turns", &self.last_n_assistant_turns)
.finish()
}
}
#[derive(Debug, Default, Clone)]
pub struct CompactionReminderBuilder {
max_chars: usize,
}
impl CompactionReminderBuilder {
pub fn new() -> Self {
Self { max_chars: 800 }
}
pub fn with_max_chars(mut self, max_chars: usize) -> Self {
self.max_chars = max_chars;
self
}
pub fn build(&self, inputs: &ActiveReminderInputs<'_>) -> String {
let mut sections = Vec::new();
if let Some(todos) = inputs.todos {
let phases = todos.get_phases();
if !phases.is_empty() {
let summary = todo_summary(&phases);
if !summary.is_empty() {
sections.push(summary);
}
}
}
let joined = sections.join("\n");
if self.max_chars == 0 || joined.len() <= self.max_chars {
joined
} else {
let mut end = self.max_chars.saturating_sub(3);
while end > 0 && !joined.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &joined[..end])
}
}
}
fn todo_summary(phases: &[TodoPhase]) -> String {
let mut in_progress = 0usize;
let mut pending = 0usize;
let mut total = 0usize;
for phase in phases {
for task in &phase.tasks {
total += 1;
match task.status {
TodoStatus::InProgress => in_progress += 1,
TodoStatus::Pending => pending += 1,
TodoStatus::Completed | TodoStatus::Abandoned => {}
}
}
}
if total == 0 {
return String::new();
}
let actionable = in_progress + pending;
if actionable == 0 {
return String::new();
}
let mut parts = Vec::new();
parts.push(format!(
"Active todos: {} in progress, {} pending ({} phases)",
in_progress,
pending,
phases.len(),
));
let active_phase_names: Vec<&str> = phases
.iter()
.filter(|p| {
p.tasks
.iter()
.any(|t| matches!(t.status, TodoStatus::InProgress))
})
.take(3)
.map(|p| p.name.as_str())
.collect();
if !active_phase_names.is_empty() {
parts.push(format!("Current phases: {}", active_phase_names.join(", ")));
}
parts.join("\n")
}
pub fn combine_instruction(static_part: Option<String>, reminder: String) -> Option<String> {
let dynamic = if reminder.is_empty() {
None
} else {
Some(reminder)
};
match (static_part, dynamic) {
(None, None) => None,
(Some(s), None) => Some(s),
(None, Some(d)) => Some(d),
(Some(s), Some(d)) => Some(format!("{}\n\nFocus areas after compaction:\n{}", s, d)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::ToolError;
use crate::tools::todo::{TodoItem, TodoOp, TodoPhase, TodoStatus, TodoUpdateResult};
#[derive(Debug)]
struct StubTodos(Vec<TodoPhase>);
impl TodoStateProvider for StubTodos {
fn get_phases(&self) -> Vec<TodoPhase> {
self.0.clone()
}
fn apply_ops<'a>(
&'a self,
_ops: Vec<TodoOp>,
) -> std::pin::Pin<Box<dyn Future<Output = Result<TodoUpdateResult, ToolError>> + Send + 'a>>
{
Box::pin(async move { Err("stub does not accept ops".to_string()) })
}
}
fn phase<S, I>(name: &str, tasks: I) -> TodoPhase
where
I: IntoIterator<Item = (S, TodoStatus)>,
S: Into<String>,
{
TodoPhase {
name: name.to_string(),
tasks: tasks
.into_iter()
.map(|(content, status)| TodoItem {
content: content.into(),
status,
notes: None,
})
.collect(),
}
}
#[test]
fn build_returns_empty_when_no_inputs() {
let b = CompactionReminderBuilder::new();
let inputs = ActiveReminderInputs::default();
assert_eq!(b.build(&inputs), "");
}
#[test]
fn build_returns_empty_when_todo_provider_is_none() {
let b = CompactionReminderBuilder::new();
let inputs = ActiveReminderInputs {
todos: None,
..Default::default()
};
assert_eq!(b.build(&inputs), "");
}
#[test]
fn build_returns_empty_when_all_tasks_completed() {
let provider = StubTodos(vec![phase(
"phase-a",
vec![
("done-1", TodoStatus::Completed),
("done-2", TodoStatus::Completed),
],
)]);
let b = CompactionReminderBuilder::new();
let inputs = ActiveReminderInputs {
todos: Some(&provider),
..Default::default()
};
assert_eq!(b.build(&inputs), "");
}
#[test]
fn build_lists_active_todos() {
let provider = StubTodos(vec![
phase(
"phase-a",
vec![
("working", TodoStatus::InProgress),
("todo", TodoStatus::Pending),
("done", TodoStatus::Completed),
],
),
phase("phase-b", vec![("waiting", TodoStatus::Pending)]),
]);
let b = CompactionReminderBuilder::new();
let inputs = ActiveReminderInputs {
todos: Some(&provider),
..Default::default()
};
let out = b.build(&inputs);
assert!(out.contains("Active todos: 1 in progress, 2 pending"));
assert!(out.contains("2 phases"));
assert!(out.contains("phase-a"));
assert!(
!out.contains("phase-b"),
"non-active phase should be omitted: {out}"
);
}
#[test]
fn max_chars_caps_output() {
let provider = StubTodos(vec![phase(
"long-phase",
(0..50)
.map(|i| (format!("task-{i}"), TodoStatus::Pending))
.collect::<Vec<_>>(),
)]);
let b = CompactionReminderBuilder::new().with_max_chars(30);
let inputs = ActiveReminderInputs {
todos: Some(&provider),
..Default::default()
};
let out = b.build(&inputs);
assert!(out.len() <= 30, "output not capped: {} chars", out.len());
assert!(out.ends_with("..."));
}
#[test]
fn combine_instruction_handles_all_combinations() {
assert_eq!(combine_instruction(None, String::new()), None);
assert_eq!(
combine_instruction(Some("static".into()), String::new()),
Some("static".into())
);
assert_eq!(
combine_instruction(None, "dynamic".into()),
Some("dynamic".into())
);
assert_eq!(
combine_instruction(Some("static".into()), "dynamic".into()),
Some("static\n\nFocus areas after compaction:\ndynamic".into())
);
}
}