Skip to main content

oxi_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}
31
32impl TodoStatus {
33    /// Return a status-specific glyph for display.
34    pub fn icon(self) -> &'static str {
35        match self {
36            Self::Pending => "\u{2610}",    // ☐
37            Self::InProgress => "\u{25B6}", // ▶
38            Self::Completed => "\u{2611}",  // ☑
39            Self::Abandoned => "\u{2717}",  // ✗
40        }
41    }
42
43    /// Return the serialized snake_case name of this status.
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Self::Pending => "pending",
47            Self::InProgress => "in_progress",
48            Self::Completed => "completed",
49            Self::Abandoned => "abandoned",
50        }
51    }
52}
53
54impl fmt::Display for TodoStatus {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str(self.as_str())
57    }
58}
59
60/// A single task within a phase.
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
62pub struct TodoItem {
63    /// Human-readable description of the task.
64    pub content: String,
65    /// Current lifecycle status of the task.
66    pub status: TodoStatus,
67    /// Optional free-form notes attached to the task.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub notes: Option<Vec<String>>,
70}
71
72/// A named group of related tasks within a todo list.
73#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
74pub struct TodoPhase {
75    /// Display name of the phase.
76    pub name: String,
77    /// Tasks belonging to this phase, in order.
78    pub tasks: Vec<TodoItem>,
79}
80
81/// Operations that can be applied to a todo list.
82#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
83#[serde(tag = "op", rename_all = "snake_case")]
84pub enum TodoOp {
85    /// Initialize or replace the todo list.
86    Init {
87        /// Optional structured phase definitions.
88        #[serde(default)]
89        list: Option<Vec<InitListEntry>>,
90        /// Optional flat list of task contents.
91        #[serde(default)]
92        items: Option<Vec<String>>,
93    },
94    /// Mark matching tasks as in progress.
95    Start {
96        /// Task content filter.
97        #[serde(default)]
98        task: Option<String>,
99        /// Phase name filter.
100        #[serde(default)]
101        phase: Option<String>,
102    },
103    /// Mark matching tasks as completed.
104    Done {
105        /// Task content filter.
106        #[serde(default)]
107        task: Option<String>,
108        /// Phase name filter.
109        #[serde(default)]
110        phase: Option<String>,
111    },
112    /// Mark matching tasks as abandoned.
113    Drop {
114        /// Task content filter.
115        #[serde(default)]
116        task: Option<String>,
117        /// Phase name filter.
118        #[serde(default)]
119        phase: Option<String>,
120    },
121    /// Remove matching tasks entirely.
122    Rm {
123        /// Task content filter.
124        #[serde(default)]
125        task: Option<String>,
126        /// Phase name filter.
127        #[serde(default)]
128        phase: Option<String>,
129    },
130    /// Append tasks to a phase, creating it if it does not exist.
131    Append {
132        /// Name of the target phase.
133        phase: String,
134        /// Task contents to append.
135        items: Vec<String>,
136    },
137    /// Return the current state without modifying it.
138    View,
139}
140
141/// A phase seed supplied to the `init` op.
142#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
143pub struct InitListEntry {
144    /// Display name of the phase.
145    pub phase: String,
146    /// Initial task contents for the phase.
147    pub items: Vec<String>,
148}
149
150/// Describes a task that newly transitioned to completed.
151#[derive(Debug, Clone, serde::Serialize)]
152pub struct TodoCompletionTransition {
153    /// Name of the phase containing the task.
154    pub phase: String,
155    /// Content of the completed task.
156    pub content: String,
157}
158
159/// Result of applying a batch of todo ops.
160#[derive(Debug, Clone, serde::Serialize)]
161pub struct TodoUpdateResult {
162    /// Full phase list after the ops were applied.
163    pub phases: Vec<TodoPhase>,
164    /// Tasks that transitioned to completed during this update.
165    pub completed_tasks: Vec<TodoCompletionTransition>,
166    /// Non-fatal errors collected while applying the ops.
167    pub errors: Vec<String>,
168}
169
170// ── Op dispatch (omp `applyEntry` 계약) ─────────────────────────────
171
172/// Apply a single op to the phases vec. Errors are collected, not fatal.
173fn apply_entry(phases: &mut Vec<TodoPhase>, op: &TodoOp, errors: &mut Vec<String>) {
174    match op {
175        TodoOp::Init { list, items } => {
176            *phases = init_phases(list.as_deref(), items.as_deref(), errors);
177        }
178        TodoOp::Start { task, phase } => {
179            let targets = resolve_targets(phases, task.as_deref(), phase.as_deref(), errors);
180            for (phase_idx, task_idx) in targets {
181                phases[phase_idx].tasks[task_idx].status = TodoStatus::InProgress;
182            }
183        }
184        TodoOp::Done { task, phase } => {
185            transition_status(
186                phases,
187                task.as_deref(),
188                phase.as_deref(),
189                TodoStatus::Completed,
190                errors,
191            );
192        }
193        TodoOp::Drop { task, phase } => {
194            transition_status(
195                phases,
196                task.as_deref(),
197                phase.as_deref(),
198                TodoStatus::Abandoned,
199                errors,
200            );
201        }
202        TodoOp::Rm { task, phase } => {
203            remove_tasks(phases, task.as_deref(), phase.as_deref(), errors);
204        }
205        TodoOp::Append { phase, items } => {
206            append_items(phases, phase, items);
207        }
208        TodoOp::View => {} // read-only
209    }
210}
211
212const DEFAULT_INIT_PHASE: &str = "Tasks";
213
214fn init_phases(
215    list: Option<&[InitListEntry]>,
216    items: Option<&[String]>,
217    errors: &mut Vec<String>,
218) -> Vec<TodoPhase> {
219    if let Some(list) = list {
220        list.iter()
221            .map(|entry| TodoPhase {
222                name: entry.phase.clone(),
223                tasks: entry
224                    .items
225                    .iter()
226                    .map(|c| TodoItem {
227                        content: c.clone(),
228                        status: TodoStatus::Pending,
229                        notes: None,
230                    })
231                    .collect(),
232            })
233            .collect()
234    } else if let Some(items) = items {
235        vec![TodoPhase {
236            name: DEFAULT_INIT_PHASE.into(),
237            tasks: items
238                .iter()
239                .map(|c| TodoItem {
240                    content: c.clone(),
241                    status: TodoStatus::Pending,
242                    notes: None,
243                })
244                .collect(),
245        }]
246    } else {
247        errors.push("init requires either 'list' or 'items'".into());
248        Vec::new()
249    }
250}
251
252fn resolve_targets(
253    phases: &[TodoPhase],
254    task: Option<&str>,
255    phase: Option<&str>,
256    errors: &mut Vec<String>,
257) -> Vec<(usize, usize)> {
258    let mut out = Vec::new();
259    for (pi, p) in phases.iter().enumerate() {
260        if phase.is_some_and(|phase_name| p.name != phase_name) {
261            continue;
262        }
263        for (ti, t) in p.tasks.iter().enumerate() {
264            if task.is_some_and(|task_content| t.content != task_content) {
265                continue;
266            }
267            out.push((pi, ti));
268        }
269    }
270    if out.is_empty() {
271        let target = match (phase, task) {
272            (Some(p), Some(t)) => format!("phase '{}' task '{}'", p, t),
273            (Some(p), None) => format!("phase '{}'", p),
274            (None, Some(t)) => format!("task '{}'", t),
275            (None, None) => "any task".to_string(),
276        };
277        errors.push(format!("No matching {} found", target));
278    }
279    out
280}
281
282fn transition_status(
283    phases: &mut [TodoPhase],
284    task: Option<&str>,
285    phase: Option<&str>,
286    new_status: TodoStatus,
287    errors: &mut Vec<String>,
288) {
289    let targets = resolve_targets(phases, task, phase, errors);
290    for (pi, ti) in targets {
291        phases[pi].tasks[ti].status = new_status;
292    }
293}
294
295fn append_items(phases: &mut Vec<TodoPhase>, phase_name: &str, items: &[String]) {
296    let phase = if let Some(p) = phases.iter_mut().find(|p| p.name == phase_name) {
297        p
298    } else {
299        phases.push(TodoPhase {
300            name: phase_name.into(),
301            tasks: Vec::new(),
302        });
303        match phases.last_mut() {
304            Some(last) => last,
305            None => return,
306        }
307    };
308    for content in items {
309        phase.tasks.push(TodoItem {
310            content: content.clone(),
311            status: TodoStatus::Pending,
312            notes: None,
313        });
314    }
315}
316
317fn remove_tasks(
318    phases: &mut Vec<TodoPhase>,
319    task: Option<&str>,
320    phase: Option<&str>,
321    errors: &mut Vec<String>,
322) {
323    if task.is_none() && phase.is_none() {
324        // 둘 다 생략 → 전체 삭제
325        phases.clear();
326        return;
327    }
328    let mut errors_local = Vec::new();
329    let targets = resolve_targets(phases, task, phase, &mut errors_local);
330    errors.extend(errors_local);
331    // 역순 제거 (인덱스 보존)
332    let mut to_remove: Vec<(usize, usize)> = targets;
333    to_remove.sort_by(|a, b| b.cmp(a));
334    for (pi, ti) in to_remove {
335        if pi < phases.len() && ti < phases[pi].tasks.len() {
336            phases[pi].tasks.remove(ti);
337        }
338    }
339    // 빈 phase 제거
340    phases.retain(|p| !p.tasks.is_empty());
341}
342
343// ── 정규화 & 완료 전환 ──────────────────────────────────────────────
344
345/// 한 phase에 in_progress task가 2개 이상이면 첫 번째만 유지.
346/// omp `normalizeInProgressTask` 계약.
347fn normalize_in_progress(phases: &mut [TodoPhase]) {
348    let mut found = false;
349    for phase in phases.iter_mut().rev() {
350        for task in &mut phase.tasks {
351            if task.status == TodoStatus::InProgress {
352                if found {
353                    task.status = TodoStatus::Pending;
354                } else {
355                    found = true;
356                }
357            }
358        }
359    }
360}
361
362/// 이전/이후 phase 배열을 비교해 새로 Completed가 된 task 목록.
363/// TUI 스트라이크루 애니메이션 트리거용.
364fn get_completion_transitions(
365    previous: &[TodoPhase],
366    updated: &[TodoPhase],
367) -> Vec<TodoCompletionTransition> {
368    let mut out = Vec::new();
369    for new_phase in updated {
370        let old_phase = previous.iter().find(|p| p.name == new_phase.name);
371        for new_task in &new_phase.tasks {
372            if new_task.status != TodoStatus::Completed {
373                continue;
374            }
375            let was_completed = old_phase
376                .and_then(|p| p.tasks.iter().find(|t| t.content == new_task.content))
377                .is_some_and(|t| t.status == TodoStatus::Completed);
378            if !was_completed {
379                out.push(TodoCompletionTransition {
380                    phase: new_phase.name.clone(),
381                    content: new_task.content.clone(),
382                });
383            }
384        }
385    }
386    out
387}
388
389/// todo 내용과 서브에이전트 설명이 같은 작업을 가리키는지.
390/// 6자 이상 중복 정규화 매칭 (omp TODO_DESCRIPTION_MIN_OVERLAP).
391pub fn todo_matches_any_description(content: &str, descriptions: &[String]) -> bool {
392    let normalized = normalize_for_match(content);
393    if normalized.len() < 6 {
394        return false;
395    }
396    descriptions.iter().any(|d| {
397        let d_norm = normalize_for_match(d);
398        d_norm.contains(&normalized) || normalized.contains(&d_norm)
399    })
400}
401
402fn normalize_for_match(s: &str) -> String {
403    let mut out = String::with_capacity(s.len());
404    let mut prev_space = false;
405    for c in s.chars() {
406        let lc = c.to_ascii_lowercase();
407        if lc.is_whitespace() {
408            if !prev_space {
409                out.push(' ');
410            }
411            prev_space = true;
412        } else {
413            out.push(lc);
414            prev_space = false;
415        }
416    }
417    out.trim().to_string()
418}
419
420// ── Markdown 라운드트립 ──────────────────────────────────────────────
421
422/// phases → Markdown 체크리스트. 다중 phase면 로마 숫자 헤더.
423pub fn phases_to_markdown(phases: &[TodoPhase]) -> String {
424    let mut out = String::new();
425    for (i, phase) in phases.iter().enumerate() {
426        if phases.len() > 1 {
427            out.push_str(&format!("{}. {}\n", roman_numeral(i + 1), phase.name));
428        }
429        for task in &phase.tasks {
430            let marker = match task.status {
431                TodoStatus::Completed => "- [x]",
432                TodoStatus::Abandoned => "- [-]",
433                _ => "- [ ]",
434            };
435            out.push_str(&format!("  {} {}\n", marker, task.content));
436        }
437    }
438    out
439}
440
441const ROMAN_PAIRS: &[(u32, &str)] = &[
442    (1000, "M"),
443    (900, "CM"),
444    (500, "D"),
445    (400, "CD"),
446    (100, "C"),
447    (90, "XC"),
448    (50, "L"),
449    (40, "XL"),
450    (10, "X"),
451    (9, "IX"),
452    (5, "V"),
453    (4, "IV"),
454    (1, "I"),
455];
456
457fn roman_numeral(mut n: usize) -> String {
458    let mut out = String::new();
459    for &(value, sym) in ROMAN_PAIRS {
460        while n >= value as usize {
461            out.push_str(sym);
462            n -= value as usize;
463        }
464    }
465    out
466}
467
468/// Markdown 체크리스트 → phases. 헤더 (`## Phase` 또는 `N. Phase`)와 체크박스 파싱.
469/// omp `markdownToPhases` 계약.
470pub fn markdown_to_phases(md: &str) -> Result<Vec<TodoPhase>, String> {
471    let mut phases: Vec<TodoPhase> = Vec::new();
472    let mut current_phase: Option<TodoPhase> = None;
473
474    for line in md.lines() {
475        let trimmed = line.trim_end();
476        if let Some(name) = parse_phase_header(trimmed) {
477            if let Some(p) = current_phase.take() {
478                phases.push(p);
479            }
480            current_phase = Some(TodoPhase {
481                name,
482                tasks: Vec::new(),
483            });
484        } else if let Some((status, content)) = parse_task_line(trimmed) {
485            let target = current_phase.get_or_insert_with(|| TodoPhase {
486                name: DEFAULT_INIT_PHASE.into(),
487                tasks: Vec::new(),
488            });
489            target.tasks.push(TodoItem {
490                content,
491                status,
492                notes: None,
493            });
494        }
495    }
496    if let Some(p) = current_phase {
497        phases.push(p);
498    }
499    Ok(phases)
500}
501
502fn parse_phase_header(line: &str) -> Option<String> {
503    let t = line.trim();
504    // ## Phase Name
505    if let Some(rest) = t.strip_prefix("## ") {
506        return Some(rest.trim().to_string());
507    }
508    // I. Phase Name  /  II. Phase Name
509    for prefix_len in 1..=6 {
510        if t.len() <= prefix_len {
511            break;
512        }
513        let prefix = &t[..prefix_len];
514        if prefix.ends_with('.')
515            && prefix[..prefix_len - 1]
516                .chars()
517                .all(|c| c.is_ascii_uppercase())
518        {
519            let rest = t[prefix_len..].trim();
520            if !rest.is_empty() {
521                return Some(rest.to_string());
522            }
523        }
524    }
525    None
526}
527
528fn parse_task_line(line: &str) -> Option<(TodoStatus, String)> {
529    let t = line.trim();
530    if let Some(rest) = t.strip_prefix("- [x] ") {
531        return Some((TodoStatus::Completed, rest.to_string()));
532    }
533    if let Some(rest) = t.strip_prefix("- [X] ") {
534        return Some((TodoStatus::Completed, rest.to_string()));
535    }
536    if let Some(rest) = t.strip_prefix("- [-] ") {
537        return Some((TodoStatus::Abandoned, rest.to_string()));
538    }
539    if let Some(rest) = t.strip_prefix("- [ ] ") {
540        return Some((TodoStatus::Pending, rest.to_string()));
541    }
542    None
543}
544
545// ── 요약 포맷 ────────────────────────────────────────────────────────
546
547/// Render a human-readable summary of the todo list for display.
548pub fn format_summary(phases: &[TodoPhase], errors: &[String], read_only: bool) -> String {
549    let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
550    let done: usize = phases
551        .iter()
552        .map(|p| {
553            p.tasks
554                .iter()
555                .filter(|t| t.status == TodoStatus::Completed)
556                .count()
557        })
558        .sum();
559
560    let mut out = if read_only {
561        format!(
562            "\u{1F4CB} Todo list (read-only) — {}/{} done\n\n",
563            done, total
564        )
565    } else if errors.is_empty() {
566        format!("\u{2713} Todo updated — {}/{} done\n\n", done, total)
567    } else {
568        format!(
569            "\u{26A0} Todo updated with {} error(s) — {}/{} done\n\n",
570            errors.len(),
571            done,
572            total
573        )
574    };
575
576    for (i, phase) in phases.iter().enumerate() {
577        if phases.len() > 1 {
578            out.push_str(&format!("{}. {}\n", roman_numeral(i + 1), phase.name));
579        }
580        for task in &phase.tasks {
581            out.push_str(&format!("  {} {}\n", task.status.icon(), task.content));
582        }
583    }
584
585    for err in errors {
586        out.push_str(&format!("  \u{26A0} {}\n", err));
587    }
588
589    out
590}
591
592// ── Apply ops helper ─────────────────────────────────────────────────
593
594/// Apply a sequence of ops, returning the result + transitions + errors.
595pub fn apply_ops(phases: &mut Vec<TodoPhase>, ops: &[TodoOp]) -> TodoUpdateResult {
596    let old_phases = phases.clone();
597    let mut errors = Vec::new();
598    for op in ops {
599        apply_entry(phases, op, &mut errors);
600    }
601    normalize_in_progress(phases);
602    let completed_tasks = get_completion_transitions(&old_phases, phases);
603    TodoUpdateResult {
604        phases: phases.clone(),
605        completed_tasks,
606        errors,
607    }
608}
609
610/// Trait abstracting where todo state lives. Implemented by hosts
611/// (e.g. `oxi-cli::store::TodoState`) so the stateless `TodoTool` and
612/// the TUI sticky panel share one source of truth.
613pub trait TodoStateProvider: Send + Sync {
614    /// Snapshot of the current phases (cheap clone expected).
615    fn get_phases(&self) -> Vec<TodoPhase>;
616
617    /// Apply a batch of ops asynchronously. The returned future is
618    /// `Send` so it can be driven from a worker task.
619    fn apply_ops<'a>(
620        &'a self,
621        ops: Vec<TodoOp>,
622    ) -> std::pin::Pin<
623        Box<dyn std::future::Future<Output = Result<TodoUpdateResult, ToolError>> + Send + 'a>,
624    >;
625}
626// ── TodoTool (AgentTool 구현) ─────────────────────────────────────────
627
628/// `todo` agent tool. 상태 비저장 (상태는 `TodoStateProvider`가 보유).
629pub struct TodoTool;
630
631#[async_trait]
632impl AgentTool for TodoTool {
633    fn name(&self) -> &str {
634        "todo"
635    }
636
637    fn label(&self) -> &str {
638        "Todo"
639    }
640
641    fn essential(&self) -> bool {
642        false
643    }
644
645    fn description(&self) -> &str {
646        "Phased todo list manager. Use init to create a plan, start/done/drop \
647         to transition tasks, append to add, rm to remove, view to read. \
648         Tasks should be 5-10 words describing WHAT not HOW."
649    }
650
651    fn parameters_schema(&self) -> Value {
652        json!({
653            "type": "object",
654            "properties": {
655                "ops": {
656                    "type": "array",
657                    "minItems": 1,
658                    "items": {
659                        "type": "object",
660                        "properties": {
661                            "op": {
662                                "type": "string",
663                                "enum": ["init", "start", "done", "drop", "rm", "append", "view"]
664                            },
665                            "task": {"type": "string", "description": "Task content (verbatim)"},
666                            "phase": {"type": "string", "description": "Phase name"},
667                            "items": {"type": "array", "items": {"type": "string"}},
668                            "list": {
669                                "type": "array",
670                                "items": {
671                                    "type": "object",
672                                    "properties": {
673                                        "phase": {"type": "string"},
674                                        "items": {"type": "array", "items": {"type": "string"}}
675                                    }
676                                }
677                            }
678                        },
679                        "required": ["op"]
680                    }
681                }
682            },
683            "required": ["ops"]
684        })
685    }
686
687    async fn execute(
688        &self,
689        _tool_call_id: &str,
690        params: Value,
691        _signal: Option<tokio::sync::oneshot::Receiver<()>>,
692        ctx: &ToolContext,
693    ) -> Result<AgentToolResult, ToolError> {
694        // v2: 능력 특성 주입 (ToolContext.todo)
695        let provider = ctx.todo.as_ref().ok_or("Todo not configured")?;
696
697        let ops_value = params
698            .get("ops")
699            .cloned()
700            .ok_or_else(|| "Missing required parameter: ops".to_string())?;
701
702        let ops: Vec<TodoOp> =
703            serde_json::from_value(ops_value).map_err(|e| format!("Invalid ops format: {}", e))?;
704
705        let result = provider.apply_ops(ops).await?;
706
707        let summary = format_summary(&result.phases, &result.errors, false);
708        Ok(AgentToolResult::success(summary))
709    }
710}
711
712// ── Tests ────────────────────────────────────────────────────────────
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    fn make_task(content: &str, status: TodoStatus) -> TodoItem {
719        TodoItem {
720            content: content.into(),
721            status,
722            notes: None,
723        }
724    }
725
726    #[test]
727    fn init_with_phased_list() {
728        let mut phases = vec![];
729        let mut errors = vec![];
730        apply_entry(
731            &mut phases,
732            &TodoOp::Init {
733                list: Some(vec![
734                    InitListEntry {
735                        phase: "A".into(),
736                        items: vec!["a1".into(), "a2".into()],
737                    },
738                    InitListEntry {
739                        phase: "B".into(),
740                        items: vec!["b1".into()],
741                    },
742                ]),
743                items: None,
744            },
745            &mut errors,
746        );
747        assert_eq!(phases.len(), 2);
748        assert_eq!(phases[0].name, "A");
749        assert_eq!(phases[0].tasks.len(), 2);
750        assert_eq!(phases[1].name, "B");
751        assert!(errors.is_empty());
752    }
753
754    #[test]
755    fn init_with_flat_items_uses_default_phase() {
756        let mut phases = vec![];
757        let mut errors = vec![];
758        apply_entry(
759            &mut phases,
760            &TodoOp::Init {
761                list: None,
762                items: Some(vec!["task1".into(), "task2".into()]),
763            },
764            &mut errors,
765        );
766        assert_eq!(phases.len(), 1);
767        assert_eq!(phases[0].name, "Tasks");
768        assert_eq!(phases[0].tasks.len(), 2);
769    }
770
771    #[test]
772    fn init_without_list_or_items_errors() {
773        let mut phases = vec![];
774        let mut errors = vec![];
775        apply_entry(
776            &mut phases,
777            &TodoOp::Init {
778                list: None,
779                items: None,
780            },
781            &mut errors,
782        );
783        assert_eq!(errors.len(), 1);
784    }
785
786    #[test]
787    fn start_normalizes_other_in_progress() {
788        let mut phases = vec![TodoPhase {
789            name: "A".into(),
790            tasks: vec![
791                make_task("a1", TodoStatus::Pending),
792                make_task("a2", TodoStatus::Pending),
793            ],
794        }];
795
796        let result = apply_ops(
797            &mut phases,
798            &[
799                TodoOp::Start {
800                    task: Some("a1".into()),
801                    phase: None,
802                },
803                TodoOp::Start {
804                    task: Some("a2".into()),
805                    phase: None,
806                },
807            ],
808        );
809        assert!(result.errors.is_empty());
810        // omp 동작: 단일 phase에서 첫 task가 in_progress 유지, 이후는 pending으로 리셋.
811        let a1 = phases[0].tasks.iter().find(|t| t.content == "a1").unwrap();
812        let a2 = phases[0].tasks.iter().find(|t| t.content == "a2").unwrap();
813        assert_eq!(a1.status, TodoStatus::InProgress);
814        assert_eq!(a2.status, TodoStatus::Pending);
815    }
816
817    #[test]
818    fn completion_transition_detects_newly_completed() {
819        let old = vec![TodoPhase {
820            name: "A".into(),
821            tasks: vec![make_task("a1", TodoStatus::InProgress)],
822        }];
823        let updated = vec![TodoPhase {
824            name: "A".into(),
825            tasks: vec![make_task("a1", TodoStatus::Completed)],
826        }];
827        let transitions = get_completion_transitions(&old, &updated);
828        assert_eq!(transitions.len(), 1);
829        assert_eq!(transitions[0].content, "a1");
830    }
831
832    #[test]
833    fn completion_transition_excludes_already_completed() {
834        let old = vec![TodoPhase {
835            name: "A".into(),
836            tasks: vec![make_task("a1", TodoStatus::Completed)],
837        }];
838        let updated = old.clone();
839        let transitions = get_completion_transitions(&old, &updated);
840        assert!(transitions.is_empty());
841    }
842
843    #[test]
844    fn todo_matches_subagent_description() {
845        // 동일 substring 매칭: 길이 ≥ 6.
846        assert!(todo_matches_any_description(
847            "implement authentication module",
848            &["authentication module".into()]
849        ));
850        assert!(!todo_matches_any_description(
851            "fix",
852            &["fix the bug".into()] // 6자 미만 정규화 → 매칭 안 됨
853        ));
854        assert!(!todo_matches_any_description(
855            "implement auth",
856            &["authentication module".into()] // 서로 substring 아님
857        ));
858    }
859
860    #[test]
861    fn markdown_roundtrip_preserves_state() {
862        let phases = vec![TodoPhase {
863            name: "Test".into(),
864            tasks: vec![make_task("Run tests", TodoStatus::Completed)],
865        }];
866        let md = phases_to_markdown(&phases);
867        let parsed = markdown_to_phases(&md).unwrap();
868        assert_eq!(parsed[0].tasks[0].status, TodoStatus::Completed);
869    }
870
871    #[test]
872    fn roman_numeral_correct() {
873        assert_eq!(roman_numeral(1), "I");
874        assert_eq!(roman_numeral(4), "IV");
875        assert_eq!(roman_numeral(9), "IX");
876        assert_eq!(roman_numeral(42), "XLII");
877        assert_eq!(roman_numeral(1994), "MCMXCIV");
878    }
879
880    #[test]
881    fn append_creates_phase_if_missing() {
882        let mut phases = vec![];
883        let mut errors = vec![];
884        apply_entry(
885            &mut phases,
886            &TodoOp::Append {
887                phase: "New".into(),
888                items: vec!["a".into(), "b".into()],
889            },
890            &mut errors,
891        );
892        assert_eq!(phases.len(), 1);
893        assert_eq!(phases[0].name, "New");
894        assert_eq!(phases[0].tasks.len(), 2);
895    }
896
897    #[test]
898    fn rm_with_neither_clears_all() {
899        let mut phases = vec![TodoPhase {
900            name: "X".into(),
901            tasks: vec![make_task("a", TodoStatus::Pending)],
902        }];
903        let mut errors = vec![];
904        apply_entry(
905            &mut phases,
906            &TodoOp::Rm {
907                task: None,
908                phase: None,
909            },
910            &mut errors,
911        );
912        assert!(phases.is_empty());
913    }
914
915    #[test]
916    fn done_marks_completed() {
917        let mut phases = vec![TodoPhase {
918            name: "A".into(),
919            tasks: vec![make_task("a1", TodoStatus::Pending)],
920        }];
921        let result = apply_ops(
922            &mut phases,
923            &[TodoOp::Done {
924                task: Some("a1".into()),
925                phase: None,
926            }],
927        );
928        assert!(result.errors.is_empty());
929        assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
930        assert_eq!(result.completed_tasks.len(), 1);
931    }
932
933    #[test]
934    fn drop_marks_abandoned() {
935        let mut phases = vec![TodoPhase {
936            name: "A".into(),
937            tasks: vec![make_task("a1", TodoStatus::Pending)],
938        }];
939        let result = apply_ops(
940            &mut phases,
941            &[TodoOp::Drop {
942                task: Some("a1".into()),
943                phase: None,
944            }],
945        );
946        assert!(result.errors.is_empty());
947        assert_eq!(phases[0].tasks[0].status, TodoStatus::Abandoned);
948    }
949}