Skip to main content

oxicode_agent/tools/
todo.rs

1//! Todo tool — phased task management with 7 ops.
2//!
3//! omp `tools/todo.ts` (938줄) 계약 이식:
4//! - 7 ops: init, start, done, drop, rm, append, view
5//! - 3상태 정규화 (in_progress는 한 phase에 하나)
6//! - Markdown 라운드트립
7//! - sub-agent 매칭 헬퍼 (⑥ 연동 후 활성화)
8
9use std::fmt;
10
11use async_trait::async_trait;
12use serde_json::{Value, json};
13
14use crate::{AgentTool, AgentToolResult, ToolContext, ToolError};
15
16// ── Types ─────────────────────────────────────────────────────────────
17
18/// Status of a single todo task.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum TodoStatus {
22    /// Task has not yet been started.
23    Pending,
24    /// Task is currently being worked on (at most one per phase after normalization).
25    InProgress,
26    /// Task has been finished.
27    Completed,
28    /// Task was cancelled or deemed unnecessary.
29    Abandoned,
30    /// Task is waiting on external input (a user decision, another agent, an
31    /// external service). Excluded from the stop-time incomplete-todo reminder.
32    Blocked,
33}
34
35impl TodoStatus {
36    /// Return a status-specific glyph for display.
37    pub fn icon(self) -> &'static str {
38        match self {
39            Self::Pending => "\u{2610}",    // ☐
40            Self::InProgress => "\u{25B6}", // ▶
41            Self::Completed => "\u{2611}",  // ☑
42            Self::Abandoned => "\u{2717}",  // ✗
43            Self::Blocked => "\u{23F8}",    // ⏸
44        }
45    }
46
47    /// Return the serialized snake_case name of this status.
48    pub fn as_str(self) -> &'static str {
49        match self {
50            Self::Pending => "pending",
51            Self::InProgress => "in_progress",
52            Self::Completed => "completed",
53            Self::Abandoned => "abandoned",
54            Self::Blocked => "blocked",
55        }
56    }
57}
58
59impl fmt::Display for TodoStatus {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.write_str(self.as_str())
62    }
63}
64
65/// A single task within a phase.
66#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67pub struct TodoItem {
68    /// Human-readable description of the task.
69    pub content: String,
70    /// Current lifecycle status of the task.
71    pub status: TodoStatus,
72    /// Optional free-form notes attached to the task.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub notes: Option<Vec<String>>,
75    /// Optional reason a task is blocked (set by the `block` op).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub block_reason: Option<String>,
78}
79
80/// A named group of related tasks within a todo list.
81#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
82pub struct TodoPhase {
83    /// Display name of the phase.
84    pub name: String,
85    /// Tasks belonging to this phase, in order.
86    pub tasks: Vec<TodoItem>,
87}
88
89/// Operations that can be applied to a todo list.
90#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
91#[serde(tag = "op", rename_all = "snake_case")]
92pub enum TodoOp {
93    /// Initialize or replace the todo list.
94    Init {
95        /// Optional structured phase definitions.
96        #[serde(default)]
97        list: Option<Vec<InitListEntry>>,
98        /// Optional flat list of task contents.
99        #[serde(default)]
100        items: Option<Vec<String>>,
101    },
102    /// Mark matching tasks as in progress.
103    Start {
104        /// Task content filter.
105        #[serde(default)]
106        task: Option<String>,
107        /// Phase name filter.
108        #[serde(default)]
109        phase: Option<String>,
110    },
111    /// Mark matching tasks as completed.
112    Done {
113        /// Task content filter.
114        #[serde(default)]
115        task: Option<String>,
116        /// Phase name filter.
117        #[serde(default)]
118        phase: Option<String>,
119    },
120    /// Mark matching tasks as abandoned.
121    Drop {
122        /// Task content filter.
123        #[serde(default)]
124        task: Option<String>,
125        /// Phase name filter.
126        #[serde(default)]
127        phase: Option<String>,
128    },
129    /// Remove matching tasks entirely.
130    Rm {
131        /// Task content filter.
132        #[serde(default)]
133        task: Option<String>,
134        /// Phase name filter.
135        #[serde(default)]
136        phase: Option<String>,
137    },
138    /// Append tasks to a phase, creating it if it does not exist.
139    Append {
140        /// Name of the target phase.
141        phase: String,
142        /// Task contents to append.
143        items: Vec<String>,
144    },
145    /// Mark matching tasks as blocked (waiting on external input).
146    /// Terminal states (Completed/Abandoned) are left untouched.
147    Block {
148        /// Task content filter.
149        #[serde(default)]
150        task: Option<String>,
151        /// Phase name filter.
152        #[serde(default)]
153        phase: Option<String>,
154        /// Optional human-readable reason the task is blocked.
155        #[serde(default)]
156        reason: Option<String>,
157    },
158    /// Return matching blocked tasks to `pending`.
159    Unblock {
160        /// Task content filter.
161        #[serde(default)]
162        task: Option<String>,
163        /// Phase name filter.
164        #[serde(default)]
165        phase: Option<String>,
166    },
167    /// Return the current state without modifying it.
168    View,
169}
170
171/// A phase seed supplied to the `init` op.
172#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
173pub struct InitListEntry {
174    /// Display name of the phase.
175    pub phase: String,
176    /// Initial task contents for the phase.
177    pub items: Vec<String>,
178}
179
180/// Describes a task that newly transitioned to completed.
181#[derive(Debug, Clone, serde::Serialize)]
182pub struct TodoCompletionTransition {
183    /// Name of the phase containing the task.
184    pub phase: String,
185    /// Content of the completed task.
186    pub content: String,
187}
188
189/// Result of applying a batch of todo ops.
190#[derive(Debug, Clone, serde::Serialize)]
191pub struct TodoUpdateResult {
192    /// Full phase list after the ops were applied.
193    pub phases: Vec<TodoPhase>,
194    /// Tasks that transitioned to completed during this update.
195    pub completed_tasks: Vec<TodoCompletionTransition>,
196    /// Non-fatal errors collected while applying the ops.
197    pub errors: Vec<String>,
198}
199
200// ── Op dispatch (omp `applyEntry` 계약) ─────────────────────────────
201
202/// Apply a single op to the phases vec. Errors are collected, not fatal.
203fn apply_entry(phases: &mut Vec<TodoPhase>, op: &TodoOp, errors: &mut Vec<String>) {
204    match op {
205        TodoOp::Init { list, items } => {
206            *phases = init_phases(list.as_deref(), items.as_deref(), errors);
207        }
208        TodoOp::Start { task, phase } => {
209            let targets = resolve_targets(phases, task.as_deref(), phase.as_deref(), errors);
210            for (phase_idx, task_idx) in targets {
211                phases[phase_idx].tasks[task_idx].status = TodoStatus::InProgress;
212            }
213        }
214        TodoOp::Done { task, phase } => {
215            transition_status(
216                phases,
217                task.as_deref(),
218                phase.as_deref(),
219                TodoStatus::Completed,
220                errors,
221            );
222        }
223        TodoOp::Drop { task, phase } => {
224            transition_status(
225                phases,
226                task.as_deref(),
227                phase.as_deref(),
228                TodoStatus::Abandoned,
229                errors,
230            );
231        }
232        TodoOp::Rm { task, phase } => {
233            remove_tasks(phases, task.as_deref(), phase.as_deref(), errors);
234        }
235        TodoOp::Append { phase, items } => {
236            append_items(phases, phase, items);
237        }
238        TodoOp::Block {
239            task,
240            phase,
241            reason,
242        } => {
243            block_tasks(
244                phases,
245                task.as_deref(),
246                phase.as_deref(),
247                reason.as_deref(),
248                errors,
249            );
250        }
251        TodoOp::Unblock { task, phase } => {
252            unblock_tasks(phases, task.as_deref(), phase.as_deref(), errors);
253        }
254        TodoOp::View => {} // read-only
255    }
256}
257
258const DEFAULT_INIT_PHASE: &str = "Tasks";
259
260fn init_phases(
261    list: Option<&[InitListEntry]>,
262    items: Option<&[String]>,
263    errors: &mut Vec<String>,
264) -> Vec<TodoPhase> {
265    if let Some(list) = list {
266        list.iter()
267            .map(|entry| TodoPhase {
268                name: entry.phase.clone(),
269                tasks: entry
270                    .items
271                    .iter()
272                    .map(|c| TodoItem {
273                        content: c.clone(),
274                        status: TodoStatus::Pending,
275                        notes: None,
276                        block_reason: None,
277                    })
278                    .collect(),
279            })
280            .collect()
281    } else if let Some(items) = items {
282        vec![TodoPhase {
283            name: DEFAULT_INIT_PHASE.into(),
284            tasks: items
285                .iter()
286                .map(|c| TodoItem {
287                    content: c.clone(),
288                    status: TodoStatus::Pending,
289                    notes: None,
290                    block_reason: None,
291                })
292                .collect(),
293        }]
294    } else {
295        errors.push("init requires either 'list' or 'items'".into());
296        Vec::new()
297    }
298}
299
300fn resolve_targets(
301    phases: &[TodoPhase],
302    task: Option<&str>,
303    phase: Option<&str>,
304    errors: &mut Vec<String>,
305) -> Vec<(usize, usize)> {
306    let mut out = Vec::new();
307    for (pi, p) in phases.iter().enumerate() {
308        if phase.is_some_and(|phase_name| p.name != phase_name) {
309            continue;
310        }
311        for (ti, t) in p.tasks.iter().enumerate() {
312            if task.is_some_and(|task_content| t.content != task_content) {
313                continue;
314            }
315            out.push((pi, ti));
316        }
317    }
318    if out.is_empty() {
319        let target = match (phase, task) {
320            (Some(p), Some(t)) => format!("phase '{}' task '{}'", p, t),
321            (Some(p), None) => format!("phase '{}'", p),
322            (None, Some(t)) => format!("task '{}'", t),
323            (None, None) => "any task".to_string(),
324        };
325        errors.push(format!("No matching {} found", target));
326    }
327    out
328}
329
330fn transition_status(
331    phases: &mut [TodoPhase],
332    task: Option<&str>,
333    phase: Option<&str>,
334    new_status: TodoStatus,
335    errors: &mut Vec<String>,
336) {
337    let targets = resolve_targets(phases, task, phase, errors);
338    for (pi, ti) in targets {
339        phases[pi].tasks[ti].status = new_status;
340    }
341}
342
343/// Mark matching tasks as `Blocked`, recording an optional reason. Tasks in a
344/// terminal state (`Completed`/`Abandoned`) are left untouched — blocking a
345/// finished task is a no-op rather than a silent reopening.
346fn block_tasks(
347    phases: &mut [TodoPhase],
348    task: Option<&str>,
349    phase: Option<&str>,
350    reason: Option<&str>,
351    errors: &mut Vec<String>,
352) {
353    let targets = resolve_targets(phases, task, phase, errors);
354    for (pi, ti) in targets {
355        let t = &mut phases[pi].tasks[ti];
356        if matches!(t.status, TodoStatus::Completed | TodoStatus::Abandoned) {
357            continue;
358        }
359        t.status = TodoStatus::Blocked;
360        t.block_reason = reason.map(String::from);
361    }
362}
363
364/// Return matching `Blocked` tasks to `Pending` and clear their reason. Tasks
365/// not currently blocked are left as-is, making `unblock` idempotent.
366fn unblock_tasks(
367    phases: &mut [TodoPhase],
368    task: Option<&str>,
369    phase: Option<&str>,
370    errors: &mut Vec<String>,
371) {
372    let targets = resolve_targets(phases, task, phase, errors);
373    for (pi, ti) in targets {
374        let t = &mut phases[pi].tasks[ti];
375        if t.status == TodoStatus::Blocked {
376            t.status = TodoStatus::Pending;
377            t.block_reason = None;
378        }
379    }
380}
381
382fn append_items(phases: &mut Vec<TodoPhase>, phase_name: &str, items: &[String]) {
383    let phase = if let Some(p) = phases.iter_mut().find(|p| p.name == phase_name) {
384        p
385    } else {
386        phases.push(TodoPhase {
387            name: phase_name.into(),
388            tasks: Vec::new(),
389        });
390        match phases.last_mut() {
391            Some(last) => last,
392            None => return,
393        }
394    };
395    for content in items {
396        phase.tasks.push(TodoItem {
397            content: content.clone(),
398            status: TodoStatus::Pending,
399            notes: None,
400            block_reason: None,
401        });
402    }
403}
404
405fn remove_tasks(
406    phases: &mut Vec<TodoPhase>,
407    task: Option<&str>,
408    phase: Option<&str>,
409    errors: &mut Vec<String>,
410) {
411    if task.is_none() && phase.is_none() {
412        // 둘 다 생략 → 전체 삭제
413        phases.clear();
414        return;
415    }
416    let mut errors_local = Vec::new();
417    let targets = resolve_targets(phases, task, phase, &mut errors_local);
418    errors.extend(errors_local);
419    // 역순 제거 (인덱스 보존)
420    let mut to_remove: Vec<(usize, usize)> = targets;
421    to_remove.sort_by(|a, b| b.cmp(a));
422    for (pi, ti) in to_remove {
423        if pi < phases.len() && ti < phases[pi].tasks.len() {
424            phases[pi].tasks.remove(ti);
425        }
426    }
427    // 빈 phase 제거
428    phases.retain(|p| !p.tasks.is_empty());
429}
430
431// ── 정규화 & 완료 전환 ──────────────────────────────────────────────
432
433/// 한 phase에 in_progress task가 2개 이상이면 첫 번째만 유지.
434/// omp `normalizeInProgressTask` 계약.
435fn normalize_in_progress(phases: &mut [TodoPhase]) {
436    let mut found = false;
437    for phase in phases.iter_mut().rev() {
438        for task in &mut phase.tasks {
439            if task.status == TodoStatus::InProgress {
440                if found {
441                    task.status = TodoStatus::Pending;
442                } else {
443                    found = true;
444                }
445            }
446        }
447    }
448}
449
450/// After a completion, if no task is `InProgress`, promote the earliest
451/// `Pending` task (in phase order, then task order) to `InProgress`. Blocked
452/// tasks are skipped — they wait on external input and cannot be worked on.
453/// omp "earliest still-open task auto-promotes" contract.
454fn auto_promote_next(phases: &mut [TodoPhase]) {
455    let has_in_progress = phases
456        .iter()
457        .any(|p| p.tasks.iter().any(|t| t.status == TodoStatus::InProgress));
458    if has_in_progress {
459        return;
460    }
461    for phase in phases {
462        for task in &mut phase.tasks {
463            if task.status == TodoStatus::Pending {
464                task.status = TodoStatus::InProgress;
465                return;
466            }
467        }
468    }
469}
470
471/// 이전/이후 phase 배열을 비교해 새로 Completed가 된 task 목록.
472/// TUI 스트라이크루 애니메이션 트리거용.
473fn get_completion_transitions(
474    previous: &[TodoPhase],
475    updated: &[TodoPhase],
476) -> Vec<TodoCompletionTransition> {
477    let mut out = Vec::new();
478    for new_phase in updated {
479        let old_phase = previous.iter().find(|p| p.name == new_phase.name);
480        for new_task in &new_phase.tasks {
481            if new_task.status != TodoStatus::Completed {
482                continue;
483            }
484            let was_completed = old_phase
485                .and_then(|p| p.tasks.iter().find(|t| t.content == new_task.content))
486                .is_some_and(|t| t.status == TodoStatus::Completed);
487            if !was_completed {
488                out.push(TodoCompletionTransition {
489                    phase: new_phase.name.clone(),
490                    content: new_task.content.clone(),
491                });
492            }
493        }
494    }
495    out
496}
497
498/// todo 내용과 서브에이전트 설명이 같은 작업을 가리키는지.
499/// 6자 이상 중복 정규화 매칭 (omp TODO_DESCRIPTION_MIN_OVERLAP).
500pub fn todo_matches_any_description(content: &str, descriptions: &[String]) -> bool {
501    let normalized = normalize_for_match(content);
502    if normalized.len() < 6 {
503        return false;
504    }
505    descriptions.iter().any(|d| {
506        let d_norm = normalize_for_match(d);
507        d_norm.contains(&normalized) || normalized.contains(&d_norm)
508    })
509}
510
511fn normalize_for_match(s: &str) -> String {
512    let mut out = String::with_capacity(s.len());
513    let mut prev_space = false;
514    for c in s.chars() {
515        let lc = c.to_ascii_lowercase();
516        if lc.is_whitespace() {
517            if !prev_space {
518                out.push(' ');
519            }
520            prev_space = true;
521        } else {
522            out.push(lc);
523            prev_space = false;
524        }
525    }
526    out.trim().to_string()
527}
528
529// ── Markdown 라운드트립 ──────────────────────────────────────────────
530
531/// phases → Markdown 체크리스트. 다중 phase면 로마 숫자 헤더.
532pub fn phases_to_markdown(phases: &[TodoPhase]) -> String {
533    let mut out = String::new();
534    for (i, phase) in phases.iter().enumerate() {
535        if phases.len() > 1 {
536            out.push_str(&format!("{}. {}\n", roman_numeral(i + 1), phase.name));
537        }
538        for task in &phase.tasks {
539            let marker = match task.status {
540                TodoStatus::Completed => "- [x]",
541                TodoStatus::Abandoned => "- [-]",
542                TodoStatus::Blocked => "- [!]",
543                _ => "- [ ]",
544            };
545            out.push_str(&format!("  {} {}\n", marker, task.content));
546        }
547    }
548    out
549}
550
551const ROMAN_PAIRS: &[(u32, &str)] = &[
552    (1000, "M"),
553    (900, "CM"),
554    (500, "D"),
555    (400, "CD"),
556    (100, "C"),
557    (90, "XC"),
558    (50, "L"),
559    (40, "XL"),
560    (10, "X"),
561    (9, "IX"),
562    (5, "V"),
563    (4, "IV"),
564    (1, "I"),
565];
566
567fn roman_numeral(mut n: usize) -> String {
568    let mut out = String::new();
569    for &(value, sym) in ROMAN_PAIRS {
570        while n >= value as usize {
571            out.push_str(sym);
572            n -= value as usize;
573        }
574    }
575    out
576}
577
578/// Markdown 체크리스트 → phases. 헤더 (`## Phase` 또는 `N. Phase`)와 체크박스 파싱.
579/// omp `markdownToPhases` 계약.
580pub fn markdown_to_phases(md: &str) -> Result<Vec<TodoPhase>, String> {
581    let mut phases: Vec<TodoPhase> = Vec::new();
582    let mut current_phase: Option<TodoPhase> = None;
583
584    for line in md.lines() {
585        let trimmed = line.trim_end();
586        if let Some(name) = parse_phase_header(trimmed) {
587            if let Some(p) = current_phase.take() {
588                phases.push(p);
589            }
590            current_phase = Some(TodoPhase {
591                name,
592                tasks: Vec::new(),
593            });
594        } else if let Some((status, content)) = parse_task_line(trimmed) {
595            let target = current_phase.get_or_insert_with(|| TodoPhase {
596                name: DEFAULT_INIT_PHASE.into(),
597                tasks: Vec::new(),
598            });
599            target.tasks.push(TodoItem {
600                content,
601                status,
602                notes: None,
603                block_reason: None,
604            });
605        }
606    }
607    if let Some(p) = current_phase {
608        phases.push(p);
609    }
610    Ok(phases)
611}
612
613fn parse_phase_header(line: &str) -> Option<String> {
614    let t = line.trim();
615    // ## Phase Name
616    if let Some(rest) = t.strip_prefix("## ") {
617        return Some(rest.trim().to_string());
618    }
619    // I. Phase Name  /  II. Phase Name
620    for prefix_len in 1..=6 {
621        if t.len() <= prefix_len {
622            break;
623        }
624        let prefix = &t[..prefix_len];
625        if prefix.ends_with('.')
626            && prefix[..prefix_len - 1]
627                .chars()
628                .all(|c| c.is_ascii_uppercase())
629        {
630            let rest = t[prefix_len..].trim();
631            if !rest.is_empty() {
632                return Some(rest.to_string());
633            }
634        }
635    }
636    None
637}
638
639fn parse_task_line(line: &str) -> Option<(TodoStatus, String)> {
640    let t = line.trim();
641    if let Some(rest) = t.strip_prefix("- [x] ") {
642        return Some((TodoStatus::Completed, rest.to_string()));
643    }
644    if let Some(rest) = t.strip_prefix("- [X] ") {
645        return Some((TodoStatus::Completed, rest.to_string()));
646    }
647    if let Some(rest) = t.strip_prefix("- [-] ") {
648        return Some((TodoStatus::Abandoned, rest.to_string()));
649    }
650    if let Some(rest) = t.strip_prefix("- [!] ") {
651        return Some((TodoStatus::Blocked, rest.to_string()));
652    }
653    if let Some(rest) = t.strip_prefix("- [ ] ") {
654        return Some((TodoStatus::Pending, rest.to_string()));
655    }
656    None
657}
658
659// ── 요약 포맷 ────────────────────────────────────────────────────────
660
661/// Render a human-readable summary of the todo list for display.
662pub fn format_summary(phases: &[TodoPhase], errors: &[String], read_only: bool) -> String {
663    let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
664    let done: usize = phases
665        .iter()
666        .map(|p| {
667            p.tasks
668                .iter()
669                .filter(|t| t.status == TodoStatus::Completed)
670                .count()
671        })
672        .sum();
673    let blocked: usize = phases
674        .iter()
675        .map(|p| {
676            p.tasks
677                .iter()
678                .filter(|t| t.status == TodoStatus::Blocked)
679                .count()
680        })
681        .sum();
682    let blocked_suffix = if blocked > 0 {
683        format!(", {} blocked", blocked)
684    } else {
685        String::new()
686    };
687
688    let mut out = if read_only {
689        format!(
690            "\u{1F4CB} Todo list (read-only) — {}/{} done{blocked_suffix}\n\n",
691            done, total
692        )
693    } else if errors.is_empty() {
694        format!(
695            "\u{2713} Todo updated — {}/{} done{blocked_suffix}\n\n",
696            done, total
697        )
698    } else {
699        format!(
700            "\u{26A0} Todo updated with {} error(s) — {}/{} done{blocked_suffix}\n\n",
701            errors.len(),
702            done,
703            total
704        )
705    };
706
707    for (i, phase) in phases.iter().enumerate() {
708        if phases.len() > 1 {
709            out.push_str(&format!("{}. {}\n", roman_numeral(i + 1), phase.name));
710        }
711        for task in &phase.tasks {
712            out.push_str(&format!("  {} {}\n", task.status.icon(), task.content));
713            if task.status == TodoStatus::Blocked
714                && let Some(reason) = &task.block_reason
715            {
716                out.push_str(&format!("      \u{23F8} {reason}\n"));
717            }
718        }
719    }
720
721    for err in errors {
722        out.push_str(&format!("  \u{26A0} {}\n", err));
723    }
724
725    out
726}
727
728// ── Apply ops helper ─────────────────────────────────────────────────
729
730/// Apply a sequence of ops, returning the result + transitions + errors.
731pub fn apply_ops(phases: &mut Vec<TodoPhase>, ops: &[TodoOp]) -> TodoUpdateResult {
732    let old_phases = phases.clone();
733    let mut errors = Vec::new();
734    let had_done = ops.iter().any(|op| matches!(op, TodoOp::Done { .. }));
735    for op in ops {
736        apply_entry(phases, op, &mut errors);
737    }
738    normalize_in_progress(phases);
739    // omp: on each completion the earliest still-open task auto-promotes to
740    // in_progress, so the list always points at what to work on next.
741    if had_done {
742        auto_promote_next(phases);
743    }
744    let completed_tasks = get_completion_transitions(&old_phases, phases);
745    TodoUpdateResult {
746        phases: phases.clone(),
747        completed_tasks,
748        errors,
749    }
750}
751
752// ── Stop-time incomplete-todo reminder ───────────────────────────────
753
754/// Maximum stop-reminder injections per agent run. A hard cap so a
755/// misbehaving agent (e.g. one that keeps adding todos and then stopping)
756/// cannot loop indefinitely.
757pub const MAX_TODO_STOP_REMINDERS: u32 = 3;
758
759/// State for the stop-time incomplete-todo reminder, scoped to a single
760/// agent run. [`build_stop_reminder`] mutates it to dedup unchanged
761/// open-task sets and cap total reminders.
762#[derive(Debug, Default)]
763pub struct StopReminderState {
764    last_signature: Option<String>,
765    count: u32,
766}
767
768impl StopReminderState {
769    /// Reminders emitted so far this run.
770    pub fn count(&self) -> u32 {
771        self.count
772    }
773}
774
775/// Build a stop-time reminder when the todo list has open tasks.
776///
777/// "Open" = `Pending` or `InProgress`. `Blocked` tasks are excluded — they
778/// wait on external input and are not actionable — as are `Completed` and
779/// `Abandoned`.
780///
781/// Returns `None` (leaving `state` untouched) when there is nothing open,
782/// the open set is unchanged since the last reminder, or `max` reminders
783/// have already been emitted. This bounds the agent loop's extra turns:
784/// at most `max` per run, never two in a row without the open set changing.
785pub fn build_stop_reminder(
786    phases: &[TodoPhase],
787    state: &mut StopReminderState,
788    max: u32,
789) -> Option<String> {
790    let open: Vec<&str> = phases
791        .iter()
792        .flat_map(|p| {
793            p.tasks
794                .iter()
795                .filter(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
796                .map(|t| t.content.as_str())
797        })
798        .collect();
799    if open.is_empty() {
800        return None;
801    }
802    // Signature = open task contents in order. Any change (progress,
803    // reorder, or new open tasks) re-entitles a single fresh reminder.
804    let signature = open.join("\u{1}");
805    if state.last_signature.as_deref() == Some(signature.as_str()) {
806        return None;
807    }
808    if state.count >= max {
809        return None;
810    }
811    state.last_signature = Some(signature);
812    state.count += 1;
813
814    let mut msg = format!("You still have {} incomplete todo task(s):\n", open.len());
815    for content in &open {
816        msg.push_str(&format!("- {}\n", content));
817    }
818    msg.push_str(
819        "Continue working through them, or mark each done/dropped/blocked as \
820         appropriate. Do not treat the overall request as complete while \
821         these tasks remain open.",
822    );
823    Some(msg)
824}
825
826// ── TodoTool (AgentTool 구현) ─────────────────────────────────────────
827
828/// `todo` agent tool. 상태 비저장 (상태는 `TodoStateProvider`가 보유).
829pub struct TodoTool;
830
831#[async_trait]
832impl AgentTool for TodoTool {
833    fn name(&self) -> &str {
834        "todo"
835    }
836
837    fn label(&self) -> &str {
838        "Todo"
839    }
840
841    fn essential(&self) -> bool {
842        false
843    }
844
845    fn description(&self) -> &str {
846        "Phased todo list manager. Use init to create a plan, start/done/drop \
847         to transition tasks, block/unblock to gate tasks on external input, \
848         append to add, rm to remove, view to read. On each completion the \
849         earliest still-open task auto-promotes to in_progress. Tasks should \
850         be 5-10 words describing WHAT not HOW."
851    }
852
853    fn parameters_schema(&self) -> Value {
854        json!({
855            "type": "object",
856            "properties": {
857                "ops": {
858                    "type": "array",
859                    "minItems": 1,
860                    "items": {
861                        "type": "object",
862                        "properties": {
863                            "op": {
864                                "type": "string",
865                                "enum": ["init", "start", "done", "drop", "block", "unblock", "rm", "append", "view"]
866                            },
867                            "task": {"type": "string", "description": "Task content (verbatim)"},
868                            "phase": {"type": "string", "description": "Phase name"},
869                            "reason": {"type": "string", "description": "Why the task is blocked (block op only)"},
870                            "items": {"type": "array", "items": {"type": "string"}},
871                            "list": {
872                                "type": "array",
873                                "items": {
874                                    "type": "object",
875                                    "properties": {
876                                        "phase": {"type": "string"},
877                                        "items": {"type": "array", "items": {"type": "string"}}
878                                    }
879                                }
880                            }
881                        },
882                        "required": ["op"]
883                    }
884                }
885            },
886            "required": ["ops"]
887        })
888    }
889
890    async fn execute(
891        &self,
892        _tool_call_id: &str,
893        params: Value,
894        _signal: Option<tokio::sync::oneshot::Receiver<()>>,
895        ctx: &ToolContext,
896    ) -> Result<AgentToolResult, ToolError> {
897        // v2: 능력 특성 주입 (ToolContext.todo)
898        let provider = ctx.todo.as_ref().ok_or("Todo not configured")?;
899
900        let ops_value = params
901            .get("ops")
902            .cloned()
903            .ok_or_else(|| "Missing required parameter: ops".to_string())?;
904
905        let ops: Vec<TodoOp> =
906            serde_json::from_value(ops_value).map_err(|e| format!("Invalid ops format: {}", e))?;
907
908        let result = provider.apply_ops(ops).await?;
909
910        let summary = format_summary(&result.phases, &result.errors, false);
911        Ok(AgentToolResult::success(summary))
912    }
913}
914
915// ── Tests ────────────────────────────────────────────────────────────
916
917#[cfg(test)]
918mod tests {
919    use super::*;
920
921    fn make_task(content: &str, status: TodoStatus) -> TodoItem {
922        TodoItem {
923            content: content.into(),
924            status,
925            notes: None,
926            block_reason: None,
927        }
928    }
929
930    #[test]
931    fn init_with_phased_list() {
932        let mut phases = vec![];
933        let mut errors = vec![];
934        apply_entry(
935            &mut phases,
936            &TodoOp::Init {
937                list: Some(vec![
938                    InitListEntry {
939                        phase: "A".into(),
940                        items: vec!["a1".into(), "a2".into()],
941                    },
942                    InitListEntry {
943                        phase: "B".into(),
944                        items: vec!["b1".into()],
945                    },
946                ]),
947                items: None,
948            },
949            &mut errors,
950        );
951        assert_eq!(phases.len(), 2);
952        assert_eq!(phases[0].name, "A");
953        assert_eq!(phases[0].tasks.len(), 2);
954        assert_eq!(phases[1].name, "B");
955        assert!(errors.is_empty());
956    }
957
958    #[test]
959    fn init_with_flat_items_uses_default_phase() {
960        let mut phases = vec![];
961        let mut errors = vec![];
962        apply_entry(
963            &mut phases,
964            &TodoOp::Init {
965                list: None,
966                items: Some(vec!["task1".into(), "task2".into()]),
967            },
968            &mut errors,
969        );
970        assert_eq!(phases.len(), 1);
971        assert_eq!(phases[0].name, "Tasks");
972        assert_eq!(phases[0].tasks.len(), 2);
973    }
974
975    #[test]
976    fn init_without_list_or_items_errors() {
977        let mut phases = vec![];
978        let mut errors = vec![];
979        apply_entry(
980            &mut phases,
981            &TodoOp::Init {
982                list: None,
983                items: None,
984            },
985            &mut errors,
986        );
987        assert_eq!(errors.len(), 1);
988    }
989
990    #[test]
991    fn start_normalizes_other_in_progress() {
992        let mut phases = vec![TodoPhase {
993            name: "A".into(),
994            tasks: vec![
995                make_task("a1", TodoStatus::Pending),
996                make_task("a2", TodoStatus::Pending),
997            ],
998        }];
999
1000        let result = apply_ops(
1001            &mut phases,
1002            &[
1003                TodoOp::Start {
1004                    task: Some("a1".into()),
1005                    phase: None,
1006                },
1007                TodoOp::Start {
1008                    task: Some("a2".into()),
1009                    phase: None,
1010                },
1011            ],
1012        );
1013        assert!(result.errors.is_empty());
1014        // omp 동작: 단일 phase에서 첫 task가 in_progress 유지, 이후는 pending으로 리셋.
1015        let a1 = phases[0].tasks.iter().find(|t| t.content == "a1").unwrap();
1016        let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
1017        assert_eq!(a1.status, TodoStatus::InProgress);
1018        assert_eq!(a2.status, TodoStatus::Pending);
1019    }
1020
1021    #[test]
1022    fn completion_transition_detects_newly_completed() {
1023        let old = vec![TodoPhase {
1024            name: "A".into(),
1025            tasks: vec![make_task("a1", TodoStatus::InProgress)],
1026        }];
1027        let updated = vec![TodoPhase {
1028            name: "A".into(),
1029            tasks: vec![make_task("a1", TodoStatus::Completed)],
1030        }];
1031        let transitions = get_completion_transitions(&old, &updated);
1032        assert_eq!(transitions.len(), 1);
1033        assert_eq!(transitions[0].content, "a1");
1034    }
1035
1036    #[test]
1037    fn completion_transition_excludes_already_completed() {
1038        let old = vec![TodoPhase {
1039            name: "A".into(),
1040            tasks: vec![make_task("a1", TodoStatus::Completed)],
1041        }];
1042        let updated = old.clone();
1043        let transitions = get_completion_transitions(&old, &updated);
1044        assert!(transitions.is_empty());
1045    }
1046
1047    #[test]
1048    fn todo_matches_subagent_description() {
1049        // 동일 substring 매칭: 길이 ≥ 6.
1050        assert!(todo_matches_any_description(
1051            "implement authentication module",
1052            &["authentication module".into()]
1053        ));
1054        assert!(!todo_matches_any_description(
1055            "fix",
1056            &["fix the bug".into()] // 6자 미만 정규화 → 매칭 안 됨
1057        ));
1058        assert!(!todo_matches_any_description(
1059            "implement auth",
1060            &["authentication module".into()] // 서로 substring 아님
1061        ));
1062    }
1063
1064    #[test]
1065    fn markdown_roundtrip_preserves_state() {
1066        let phases = vec![TodoPhase {
1067            name: "Test".into(),
1068            tasks: vec![make_task("Run tests", TodoStatus::Completed)],
1069        }];
1070        let md = phases_to_markdown(&phases);
1071        let parsed = markdown_to_phases(&md).unwrap();
1072        assert_eq!(parsed[0].tasks[0].status, TodoStatus::Completed);
1073    }
1074
1075    #[test]
1076    fn roman_numeral_correct() {
1077        assert_eq!(roman_numeral(1), "I");
1078        assert_eq!(roman_numeral(4), "IV");
1079        assert_eq!(roman_numeral(9), "IX");
1080        assert_eq!(roman_numeral(42), "XLII");
1081        assert_eq!(roman_numeral(1994), "MCMXCIV");
1082    }
1083
1084    #[test]
1085    fn append_creates_phase_if_missing() {
1086        let mut phases = vec![];
1087        let mut errors = vec![];
1088        apply_entry(
1089            &mut phases,
1090            &TodoOp::Append {
1091                phase: "New".into(),
1092                items: vec!["a".into(), "b".into()],
1093            },
1094            &mut errors,
1095        );
1096        assert_eq!(phases.len(), 1);
1097        assert_eq!(phases[0].name, "New");
1098        assert_eq!(phases[0].tasks.len(), 2);
1099    }
1100
1101    #[test]
1102    fn rm_with_neither_clears_all() {
1103        let mut phases = vec![TodoPhase {
1104            name: "X".into(),
1105            tasks: vec![make_task("a", TodoStatus::Pending)],
1106        }];
1107        let mut errors = vec![];
1108        apply_entry(
1109            &mut phases,
1110            &TodoOp::Rm {
1111                task: None,
1112                phase: None,
1113            },
1114            &mut errors,
1115        );
1116        assert!(phases.is_empty());
1117    }
1118
1119    #[test]
1120    fn done_marks_completed() {
1121        let mut phases = vec![TodoPhase {
1122            name: "A".into(),
1123            tasks: vec![make_task("a1", TodoStatus::Pending)],
1124        }];
1125        let result = apply_ops(
1126            &mut phases,
1127            &[TodoOp::Done {
1128                task: Some("a1".into()),
1129                phase: None,
1130            }],
1131        );
1132        assert!(result.errors.is_empty());
1133        assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
1134        assert_eq!(result.completed_tasks.len(), 1);
1135    }
1136
1137    #[test]
1138    fn drop_marks_abandoned() {
1139        let mut phases = vec![TodoPhase {
1140            name: "A".into(),
1141            tasks: vec![make_task("a1", TodoStatus::Pending)],
1142        }];
1143        let result = apply_ops(
1144            &mut phases,
1145            &[TodoOp::Drop {
1146                task: Some("a1".into()),
1147                phase: None,
1148            }],
1149        );
1150        assert!(result.errors.is_empty());
1151        assert_eq!(phases[0].tasks[0].status, TodoStatus::Abandoned);
1152    }
1153
1154    #[test]
1155    fn block_marks_blocked_with_reason() {
1156        let mut phases = vec![TodoPhase {
1157            name: "A".into(),
1158            tasks: vec![make_task("a1", TodoStatus::Pending)],
1159        }];
1160        let result = apply_ops(
1161            &mut phases,
1162            &[TodoOp::Block {
1163                task: Some("a1".into()),
1164                phase: None,
1165                reason: Some("waiting on user".into()),
1166            }],
1167        );
1168        assert!(result.errors.is_empty());
1169        assert_eq!(phases[0].tasks[0].status, TodoStatus::Blocked);
1170        assert_eq!(
1171            phases[0].tasks[0].block_reason.as_deref(),
1172            Some("waiting on user")
1173        );
1174    }
1175
1176    #[test]
1177    fn block_skips_terminal_states() {
1178        let mut phases = vec![TodoPhase {
1179            name: "A".into(),
1180            tasks: vec![make_task("done", TodoStatus::Completed)],
1181        }];
1182        apply_ops(
1183            &mut phases,
1184            &[TodoOp::Block {
1185                task: Some("done".into()),
1186                phase: None,
1187                reason: None,
1188            }],
1189        );
1190        // Completed must not be silently reopened as Blocked.
1191        assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
1192    }
1193
1194    #[test]
1195    fn unblock_returns_to_pending() {
1196        let mut phases = vec![TodoPhase {
1197            name: "A".into(),
1198            tasks: vec![TodoItem {
1199                content: "a1".into(),
1200                status: TodoStatus::Blocked,
1201                notes: None,
1202                block_reason: Some("blocked earlier".into()),
1203            }],
1204        }];
1205        let result = apply_ops(
1206            &mut phases,
1207            &[TodoOp::Unblock {
1208                task: Some("a1".into()),
1209                phase: None,
1210            }],
1211        );
1212        assert!(result.errors.is_empty());
1213        assert_eq!(phases[0].tasks[0].status, TodoStatus::Pending);
1214        assert!(phases[0].tasks[0].block_reason.is_none());
1215    }
1216
1217    #[test]
1218    fn unblock_is_idempotent_on_nonblocked() {
1219        let mut phases = vec![TodoPhase {
1220            name: "A".into(),
1221            tasks: vec![make_task("a1", TodoStatus::Pending)],
1222        }];
1223        apply_ops(
1224            &mut phases,
1225            &[TodoOp::Unblock {
1226                task: Some("a1".into()),
1227                phase: None,
1228            }],
1229        );
1230        // Pending task stays pending; no error.
1231        assert_eq!(phases[0].tasks[0].status, TodoStatus::Pending);
1232    }
1233
1234    #[test]
1235    fn done_auto_promotes_next_pending() {
1236        let mut phases = vec![TodoPhase {
1237            name: "A".into(),
1238            tasks: vec![
1239                make_task("a1", TodoStatus::InProgress),
1240                make_task("a2", TodoStatus::Pending),
1241            ],
1242        }];
1243        let result = apply_ops(
1244            &mut phases,
1245            &[TodoOp::Done {
1246                task: Some("a1".into()),
1247                phase: None,
1248            }],
1249        );
1250        assert!(result.errors.is_empty());
1251        let a1 = phases[0].tasks.iter().find(|t| t.content == "a1").unwrap();
1252        let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
1253        assert_eq!(a1.status, TodoStatus::Completed);
1254        // omp: completing a1 auto-promotes the earliest still-open task (a2).
1255        assert_eq!(a2.status, TodoStatus::InProgress);
1256    }
1257
1258    #[test]
1259    fn done_promotion_skips_blocked() {
1260        let mut phases = vec![TodoPhase {
1261            name: "A".into(),
1262            tasks: vec![
1263                make_task("a1", TodoStatus::InProgress),
1264                make_task("a2", TodoStatus::Blocked),
1265                make_task("a3", TodoStatus::Pending),
1266            ],
1267        }];
1268        apply_ops(
1269            &mut phases,
1270            &[TodoOp::Done {
1271                task: Some("a1".into()),
1272                phase: None,
1273            }],
1274        );
1275        let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
1276        let a3 = phases[0].tasks.iter().find(|t| t.content == "a3").unwrap();
1277        // Blocked a2 is skipped; a3 (the earliest Pending) is promoted.
1278        assert_eq!(a2.status, TodoStatus::Blocked);
1279        assert_eq!(a3.status, TodoStatus::InProgress);
1280    }
1281
1282    #[test]
1283    fn done_with_no_open_task_does_not_promote() {
1284        let mut phases = vec![TodoPhase {
1285            name: "A".into(),
1286            tasks: vec![make_task("only", TodoStatus::Pending)],
1287        }];
1288        apply_ops(
1289            &mut phases,
1290            &[TodoOp::Done {
1291                task: Some("only".into()),
1292                phase: None,
1293            }],
1294        );
1295        assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
1296        // Nothing left to promote; no phantom in_progress.
1297        assert!(
1298            phases[0]
1299                .tasks
1300                .iter()
1301                .all(|t| t.status != TodoStatus::InProgress)
1302        );
1303    }
1304
1305    #[test]
1306    fn start_does_not_auto_promote() {
1307        // init + start must NOT trigger promotion — only done does (omp).
1308        let mut phases = vec![TodoPhase {
1309            name: "A".into(),
1310            tasks: vec![
1311                make_task("a1", TodoStatus::Pending),
1312                make_task("a2", TodoStatus::Pending),
1313            ],
1314        }];
1315        apply_ops(
1316            &mut phases,
1317            &[TodoOp::Start {
1318                task: Some("a1".into()),
1319                phase: None,
1320            }],
1321        );
1322        let a1 = phases[0].tasks.iter().find(|t| t.content == "a1").unwrap();
1323        let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
1324        assert_eq!(a1.status, TodoStatus::InProgress);
1325        assert_eq!(a2.status, TodoStatus::Pending);
1326    }
1327
1328    #[test]
1329    fn markdown_roundtrip_blocked() {
1330        let phases = vec![TodoPhase {
1331            name: "Test".into(),
1332            tasks: vec![make_task("blocked task", TodoStatus::Blocked)],
1333        }];
1334        let md = phases_to_markdown(&phases);
1335        let parsed = markdown_to_phases(&md).unwrap();
1336        assert_eq!(parsed[0].tasks[0].status, TodoStatus::Blocked);
1337    }
1338
1339    fn open_task_phases() -> Vec<TodoPhase> {
1340        vec![TodoPhase {
1341            name: "Work".into(),
1342            tasks: vec![
1343                make_task("done task", TodoStatus::Completed),
1344                make_task("active task", TodoStatus::InProgress),
1345                make_task("open task", TodoStatus::Pending),
1346                make_task("blocked task", TodoStatus::Blocked),
1347                make_task("dropped task", TodoStatus::Abandoned),
1348            ],
1349        }]
1350    }
1351
1352    #[test]
1353    fn stop_reminder_lists_only_open_tasks() {
1354        let mut state = StopReminderState::default();
1355        let msg = build_stop_reminder(&open_task_phases(), &mut state, MAX_TODO_STOP_REMINDERS)
1356            .expect("open tasks should yield a reminder");
1357        // InProgress + Pending only; Blocked/Completed/Abandoned excluded.
1358        assert!(msg.contains("active task"));
1359        assert!(msg.contains("open task"));
1360        assert!(!msg.contains("done task"));
1361        assert!(!msg.contains("blocked task"));
1362        assert!(!msg.contains("dropped task"));
1363        assert_eq!(state.count(), 1);
1364    }
1365
1366    #[test]
1367    fn stop_reminder_none_when_all_closed() {
1368        let mut state = StopReminderState::default();
1369        let phases = vec![TodoPhase {
1370            name: "A".into(),
1371            tasks: vec![
1372                make_task("x", TodoStatus::Completed),
1373                make_task("y", TodoStatus::Abandoned),
1374                make_task("z", TodoStatus::Blocked),
1375            ],
1376        }];
1377        assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_none());
1378        assert_eq!(state.count(), 0);
1379    }
1380
1381    #[test]
1382    fn stop_reminder_dedups_unchanged_open_set() {
1383        let mut state = StopReminderState::default();
1384        let phases = open_task_phases();
1385        let first = build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS);
1386        // Same open set → no second reminder.
1387        let second = build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS);
1388        assert!(first.is_some());
1389        assert!(second.is_none());
1390        assert_eq!(state.count(), 1);
1391    }
1392
1393    #[test]
1394    fn stop_reminder_re_entitles_after_progress() {
1395        let mut state = StopReminderState::default();
1396        let phases = vec![TodoPhase {
1397            name: "A".into(),
1398            tasks: vec![make_task("a", TodoStatus::Pending)],
1399        }];
1400        assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_some());
1401        // Same set again → deduped.
1402        assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_none());
1403        // Agent completes `a`, leaving a new open task `b` → fresh reminder.
1404        let phases2 = vec![TodoPhase {
1405            name: "A".into(),
1406            tasks: vec![
1407                make_task("a", TodoStatus::Completed),
1408                make_task("b", TodoStatus::Pending),
1409            ],
1410        }];
1411        assert!(build_stop_reminder(&phases2, &mut state, MAX_TODO_STOP_REMINDERS).is_some());
1412        assert_eq!(state.count(), 2);
1413    }
1414
1415    #[test]
1416    fn stop_reminder_caps_at_max() {
1417        let mut state = StopReminderState::default();
1418        // Each iteration changes the open set so dedup never triggers; the
1419        // hard cap must still bound the count.
1420        for i in 0..MAX_TODO_STOP_REMINDERS {
1421            let phases = vec![TodoPhase {
1422                name: "A".into(),
1423                tasks: vec![make_task(&format!("task {i}"), TodoStatus::Pending)],
1424            }];
1425            assert!(
1426                build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_some(),
1427                "reminder {i} should fire"
1428            );
1429        }
1430        // Beyond the cap — even with a brand-new open set — no more reminders.
1431        let phases = vec![TodoPhase {
1432            name: "A".into(),
1433            tasks: vec![make_task("task beyond cap", TodoStatus::Pending)],
1434        }];
1435        assert!(build_stop_reminder(&phases, &mut state, MAX_TODO_STOP_REMINDERS).is_none());
1436        assert_eq!(state.count(), MAX_TODO_STOP_REMINDERS);
1437    }
1438}