Skip to main content

bamboo_tools/tools/
task.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
4use bamboo_domain::{TaskPhase, TaskPriority};
5use serde::Deserialize;
6use serde_json::json;
7use std::collections::HashMap;
8use std::collections::HashSet;
9
10#[derive(Debug, Deserialize)]
11struct TaskArgsRaw {
12    tasks: Vec<TaskWriteItem>,
13}
14
15#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
16struct TaskWriteItem {
17    #[serde(default)]
18    id: Option<String>,
19    #[serde(default, rename = "taskId")]
20    task_id: Option<String>,
21    content: String,
22    status: String,
23    #[serde(rename = "activeForm")]
24    active_form: Option<String>,
25    #[serde(default, alias = "dependsOn")]
26    depends_on: Vec<String>,
27    #[serde(default, alias = "parentId")]
28    parent_id: Option<String>,
29    #[serde(default)]
30    phase: Option<TaskPhase>,
31    #[serde(default)]
32    priority: Option<TaskPriority>,
33    #[serde(default, rename = "completionCriteria", alias = "completion_criteria")]
34    completion_criteria: Vec<String>,
35    #[serde(default, rename = "criteriaMet", alias = "criteria_met")]
36    criteria_met: Vec<String>,
37}
38
39fn normalize_required_text(value: Option<String>, field_name: &str) -> Result<String, ToolError> {
40    let Some(value) = value else {
41        return Err(ToolError::InvalidArguments(format!(
42            "{field_name} must be non-empty"
43        )));
44    };
45    let trimmed = value.trim();
46    if trimmed.is_empty() {
47        return Err(ToolError::InvalidArguments(format!(
48            "{field_name} must be non-empty"
49        )));
50    }
51    Ok(trimmed.to_string())
52}
53
54fn normalize_optional_text(value: Option<String>) -> Option<String> {
55    value
56        .map(|value| value.trim().to_string())
57        .filter(|value| !value.is_empty())
58}
59
60fn normalize_string_list(values: Vec<String>) -> Vec<String> {
61    let mut deduped = HashSet::new();
62    let mut normalized = Vec::new();
63    for value in values {
64        let trimmed = value.trim();
65        if trimmed.is_empty() {
66            continue;
67        }
68        if deduped.insert(trimmed.to_string()) {
69            normalized.push(trimmed.to_string());
70        }
71    }
72    normalized
73}
74
75fn parse_requested_task_id(
76    id: Option<String>,
77    task_id: Option<String>,
78) -> Result<Option<String>, ToolError> {
79    let id = normalize_optional_text(id);
80    let task_id = normalize_optional_text(task_id);
81    match (id, task_id) {
82        (Some(id), Some(task_id)) if id != task_id => Err(ToolError::InvalidArguments(format!(
83            "Conflicting task identifiers in tasks[]: id='{}' does not match taskId='{}'",
84            id, task_id
85        ))),
86        (Some(id), Some(_)) => Ok(Some(id)),
87        (Some(id), None) => Ok(Some(id)),
88        (None, Some(task_id)) => Ok(Some(task_id)),
89        (None, None) => Ok(None),
90    }
91}
92
93fn normalize_criterion(value: &str) -> Option<String> {
94    let normalized = value
95        .split_whitespace()
96        .collect::<Vec<_>>()
97        .join(" ")
98        .trim()
99        .to_lowercase();
100    if normalized.is_empty() {
101        None
102    } else {
103        Some(normalized)
104    }
105}
106
107fn parse_criterion_ref(value: &str) -> Option<usize> {
108    let trimmed = value.trim().to_ascii_lowercase();
109    let as_c_ref = trimmed
110        .strip_prefix("criterion_")
111        .or_else(|| trimmed.strip_prefix("criterion-"))
112        .or_else(|| trimmed.strip_prefix('c'));
113    if let Some(raw_index) = as_c_ref {
114        return raw_index.parse::<usize>().ok().filter(|index| *index > 0);
115    }
116    None
117}
118
119fn missing_completion_criteria(required: &[String], criteria_met: &[String]) -> Vec<String> {
120    let mut required_lookup = HashMap::new();
121    for (index, criterion) in required.iter().enumerate() {
122        if let Some(normalized) = normalize_criterion(criterion) {
123            required_lookup.insert(normalized, index + 1);
124        }
125    }
126
127    let mut met_refs = HashSet::new();
128    for criterion in criteria_met {
129        if let Some(index) = parse_criterion_ref(criterion) {
130            met_refs.insert(index);
131            continue;
132        }
133        if let Some(normalized) = normalize_criterion(criterion) {
134            if let Some(index) = required_lookup.get(&normalized).copied() {
135                met_refs.insert(index);
136            }
137        }
138    }
139
140    required
141        .iter()
142        .enumerate()
143        .filter_map(|(index, criterion)| {
144            if met_refs.contains(&(index + 1)) {
145                None
146            } else {
147                Some(criterion.trim().to_string())
148            }
149        })
150        .collect()
151}
152
153fn parse_numeric_task_id(value: &str) -> Option<u64> {
154    value
155        .strip_prefix("task_")
156        .and_then(|suffix| suffix.parse::<u64>().ok())
157}
158
159fn next_generated_task_id(next_counter: &mut u64, assigned_ids: &HashSet<String>) -> String {
160    loop {
161        *next_counter = next_counter.saturating_add(1);
162        let candidate = format!("task_{}", *next_counter);
163        if !assigned_ids.contains(&candidate) {
164            return candidate;
165        }
166    }
167}
168
169fn find_reusable_task_id(
170    description: &str,
171    existing_items: &[TaskItem],
172    used_existing_ids: &HashSet<String>,
173    assigned_ids: &HashSet<String>,
174) -> Option<String> {
175    existing_items
176        .iter()
177        .find(|item| {
178            item.description == description
179                && !used_existing_ids.contains(&item.id)
180                && !assigned_ids.contains(&item.id)
181        })
182        .map(|item| item.id.clone())
183}
184
185fn find_next_existing_id_by_position(
186    position: usize,
187    existing_items: &[TaskItem],
188    used_existing_ids: &HashSet<String>,
189    assigned_ids: &HashSet<String>,
190) -> Option<String> {
191    existing_items
192        .get(position)
193        .map(|item| item.id.clone())
194        .filter(|id| !used_existing_ids.contains(id) && !assigned_ids.contains(id))
195}
196
197pub struct TaskTool;
198
199impl TaskTool {
200    pub fn new() -> Self {
201        Self
202    }
203
204    pub fn task_list_from_args(
205        args: &serde_json::Value,
206        session_id: &str,
207    ) -> Result<TaskList, ToolError> {
208        Self::task_list_from_args_with_existing(args, session_id, None, None)
209    }
210
211    pub fn task_list_from_args_with_existing(
212        args: &serde_json::Value,
213        session_id: &str,
214        existing: Option<&TaskList>,
215        default_phase: Option<TaskPhase>,
216    ) -> Result<TaskList, ToolError> {
217        let parsed: TaskArgsRaw = serde_json::from_value(args.clone())
218            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Task args: {e}")))?;
219        let incoming_count = parsed.tasks.len();
220
221        let items_source = if parsed.tasks.is_empty() {
222            return Err(ToolError::InvalidArguments(
223                "Task requires a non-empty `tasks` array".to_string(),
224            ));
225        } else {
226            parsed.tasks
227        };
228
229        let existing_items = existing
230            .map(|task_list| task_list.items.clone())
231            .unwrap_or_default();
232        let mut used_existing_ids = HashSet::new();
233        let mut assigned_ids = HashSet::new();
234        let preserve_positional_ids = existing
235            .map(|task_list| task_list.items.len() == incoming_count)
236            .unwrap_or(false);
237        let mut generated_new_ids = false;
238        let mut next_generated_counter = existing_items
239            .iter()
240            .filter_map(|item| parse_numeric_task_id(&item.id))
241            .max()
242            .unwrap_or(0);
243
244        let mut items = Vec::with_capacity(items_source.len());
245        for task in items_source {
246            let description = normalize_required_text(Some(task.content), "tasks[].content")?;
247            let status = match task.status.as_str() {
248                "pending" => TaskItemStatus::Pending,
249                "in_progress" => TaskItemStatus::InProgress,
250                "completed" => TaskItemStatus::Completed,
251                "blocked" => TaskItemStatus::Blocked,
252                _ => {
253                    return Err(ToolError::InvalidArguments(format!(
254                        "Invalid task status '{}' (expected pending/in_progress/completed/blocked)",
255                        task.status
256                    )))
257                }
258            };
259
260            let requested_id = parse_requested_task_id(task.id, task.task_id)?;
261            let task_id = if let Some(requested_id) = requested_id {
262                if assigned_ids.contains(&requested_id) {
263                    return Err(ToolError::InvalidArguments(format!(
264                        "Duplicate task id '{}' in tasks[] payload",
265                        requested_id
266                    )));
267                }
268                requested_id
269            } else if let Some(reused_id) = find_reusable_task_id(
270                &description,
271                &existing_items,
272                &used_existing_ids,
273                &assigned_ids,
274            ) {
275                reused_id
276            } else if preserve_positional_ids {
277                find_next_existing_id_by_position(
278                    items.len(),
279                    &existing_items,
280                    &used_existing_ids,
281                    &assigned_ids,
282                )
283                .unwrap_or_else(|| {
284                    generated_new_ids = true;
285                    next_generated_task_id(&mut next_generated_counter, &assigned_ids)
286                })
287            } else {
288                generated_new_ids = true;
289                next_generated_task_id(&mut next_generated_counter, &assigned_ids)
290            };
291
292            assigned_ids.insert(task_id.clone());
293            let active_form = normalize_optional_text(task.active_form);
294            let existing_item = existing_items
295                .iter()
296                .find(|item| item.id == task_id)
297                .cloned();
298            if existing_item.is_some() {
299                used_existing_ids.insert(task_id.clone());
300            }
301            let notes = active_form
302                .clone()
303                .or_else(|| {
304                    existing_item
305                        .as_ref()
306                        .map(|item| item.notes.trim().to_string())
307                        .filter(|notes| !notes.is_empty())
308                })
309                .unwrap_or_else(|| description.clone());
310            let mut depends_on = normalize_string_list(task.depends_on);
311            depends_on.retain(|dependency_id| dependency_id != &task_id);
312            let mut completion_criteria = normalize_string_list(task.completion_criteria);
313            completion_criteria.retain(|criterion| !criterion.is_empty());
314            let criteria_met = normalize_string_list(task.criteria_met);
315            let mut parent_id = normalize_optional_text(task.parent_id);
316            if parent_id.as_deref() == Some(task_id.as_str()) {
317                parent_id = None;
318            }
319
320            let mut effective_status = status;
321            let mut gate_note: Option<String> = None;
322            if matches!(effective_status, TaskItemStatus::Completed)
323                && !completion_criteria.is_empty()
324            {
325                let missing = missing_completion_criteria(&completion_criteria, &criteria_met);
326                if !missing.is_empty() {
327                    effective_status = TaskItemStatus::InProgress;
328                    gate_note = Some(format!(
329                        "Completion criteria not fully met; keeping task in_progress. Missing: {}",
330                        missing.join(" | ")
331                    ));
332                }
333            }
334
335            let mut item = existing_item.unwrap_or_default();
336            item.id = task_id;
337            item.description = description.clone();
338            if item.status != effective_status {
339                item.transition_to(effective_status, gate_note.as_deref(), None);
340            }
341            item.depends_on = depends_on;
342            item.notes = if let Some(gate_note) = gate_note {
343                if notes.trim().is_empty() {
344                    gate_note
345                } else {
346                    format!("{notes}\n{gate_note}")
347                }
348            } else {
349                notes
350            };
351            item.active_form = active_form;
352            item.parent_id = parent_id;
353            item.phase = task
354                .phase
355                .unwrap_or_else(|| default_phase.as_ref().cloned().unwrap_or_default());
356            item.priority = task.priority.unwrap_or_default();
357            item.completion_criteria = completion_criteria;
358
359            items.push(item);
360        }
361
362        if !existing_items.is_empty()
363            && generated_new_ids
364            && used_existing_ids.len() < existing_items.len()
365        {
366            return Err(ToolError::InvalidArguments(
367                "Ambiguous task ID assignment during full-list rewrite. Include stable `id`/`taskId` for retained tasks when adding/removing tasks in the same update."
368                    .to_string(),
369            ));
370        }
371
372        Ok(TaskList {
373            session_id: session_id.to_string(),
374            title: "Task List".to_string(),
375            items,
376            created_at: chrono::Utc::now(),
377            updated_at: chrono::Utc::now(),
378        })
379    }
380}
381
382impl Default for TaskTool {
383    fn default() -> Self {
384        Self::new()
385    }
386}
387
388#[async_trait]
389impl Tool for TaskTool {
390    fn name(&self) -> &str {
391        "Task"
392    }
393
394    fn description(&self) -> &str {
395        "Create or update the shared task list for the current root session tree. Child sessions write to the same task list as their parent/root session."
396    }
397
398    fn parameters_schema(&self) -> serde_json::Value {
399        json!({
400            "type": "object",
401            "properties": {
402                "tasks": {
403                    "type": "array",
404                    "description": "Canonical task items for the shared task list.",
405                    "items": {
406                        "type": "object",
407                        "properties": {
408                            "id": { "type": "string" },
409                            "taskId": { "type": "string" },
410                            "content": { "type": "string", "minLength": 1 },
411                            "status": {
412                                "type": "string",
413                                "enum": ["pending", "in_progress", "completed", "blocked"]
414                            },
415                            "activeForm": { "type": "string" },
416                            "dependsOn": {
417                                "type": "array",
418                                "items": { "type": "string" }
419                            },
420                            "parentId": { "type": "string" },
421                            "phase": {
422                                "type": "string",
423                                "enum": ["planning", "execution", "verification", "handoff"]
424                            },
425                            "priority": {
426                                "type": "string",
427                                "enum": ["low", "medium", "high", "critical"]
428                            },
429                            "completionCriteria": {
430                                "type": "array",
431                                "items": { "type": "string" }
432                            },
433                            "criteriaMet": {
434                                "type": "array",
435                                "items": { "type": "string" }
436                            }
437                        },
438                        "required": ["content", "status"],
439                        "additionalProperties": false
440                    }
441                }
442            },
443            "required": ["tasks"],
444            "additionalProperties": false
445        })
446    }
447
448    async fn invoke(
449        &self,
450        args: serde_json::Value,
451        _ctx: ToolCtx,
452    ) -> Result<ToolOutcome, ToolError> {
453        let parsed: TaskArgsRaw = serde_json::from_value(args)
454            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Task args: {e}")))?;
455        let count = parsed.tasks.len();
456        if count == 0 {
457            return Err(ToolError::InvalidArguments(
458                "Task requires a non-empty `tasks` array".to_string(),
459            ));
460        }
461
462        Ok(ToolOutcome::Completed(ToolResult {
463            success: true,
464            result: format!("Task list updated with {count} items"),
465            display_preference: Some("Default".to_string()),
466            images: Vec::new(),
467        }))
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[tokio::test]
476    async fn task_execute_accepts_tasks_payload() {
477        let tool = TaskTool::new();
478        let out = tool
479            .invoke(
480                json!({
481                    "tasks": [
482                        {
483                            "content": "Summarize parser entrypoints",
484                            "status": "in_progress",
485                            "activeForm": "Summarizing parser entrypoints"
486                        }
487                    ]
488                }),
489                ToolCtx::none("t"),
490            )
491            .await
492            .expect("Task should validate payload");
493        let ToolOutcome::Completed(result) = out else {
494            panic!("expected Completed")
495        };
496
497        assert!(result.success);
498        assert!(result.result.contains("1 items"));
499    }
500
501    #[tokio::test]
502    async fn task_execute_rejects_empty_payload() {
503        let tool = TaskTool::new();
504        let err = tool
505            .invoke(json!({}), ToolCtx::none("t"))
506            .await
507            .expect_err("Task should reject empty payload");
508
509        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("tasks")));
510    }
511
512    #[tokio::test]
513    async fn task_execute_rejects_legacy_todos_field() {
514        let tool = TaskTool::new();
515        let err = tool
516            .invoke(
517                json!({
518                    "todos": [
519                        {
520                            "content": "Legacy path",
521                            "status": "pending"
522                        }
523                    ]
524                }),
525                ToolCtx::none("t"),
526            )
527            .await
528            .expect_err("Task should reject legacy todos field");
529
530        assert!(
531            matches!(err, ToolError::InvalidArguments(msg) if msg.contains("Invalid Task args"))
532        );
533    }
534
535    #[test]
536    fn task_list_from_args_supports_blocked_status() {
537        let list = TaskTool::task_list_from_args(
538            &json!({
539                "tasks": [
540                    {
541                        "content": "Waiting on API token",
542                        "status": "blocked",
543                        "activeForm": "Blocked by missing API token"
544                    }
545                ]
546            }),
547            "session_1",
548        )
549        .expect("blocked status should be accepted");
550
551        assert_eq!(list.session_id, "session_1");
552        assert_eq!(list.items.len(), 1);
553        assert_eq!(list.items[0].status, TaskItemStatus::Blocked);
554    }
555
556    #[test]
557    fn task_list_from_args_parses_structured_fields() {
558        let list = TaskTool::task_list_from_args(
559            &json!({
560                "tasks": [
561                    {
562                        "content": "Implement migration path",
563                        "status": "in_progress",
564                        "activeForm": "Implementing migration path",
565                        "dependsOn": ["task_99", "task_99", " "],
566                        "parentId": "epic_1",
567                        "phase": "verification",
568                        "priority": "high",
569                        "completionCriteria": [
570                            "All unit tests pass",
571                            "No clippy warnings",
572                            "All unit tests pass"
573                        ]
574                    }
575                ]
576            }),
577            "session_2",
578        )
579        .expect("structured fields should parse");
580
581        let item = &list.items[0];
582        assert_eq!(item.id, "task_1");
583        assert_eq!(
584            item.active_form.as_deref(),
585            Some("Implementing migration path")
586        );
587        assert_eq!(item.depends_on, vec!["task_99".to_string()]);
588        assert_eq!(item.parent_id.as_deref(), Some("epic_1"));
589        assert_eq!(item.phase, TaskPhase::Verification);
590        assert_eq!(item.priority, TaskPriority::High);
591        assert_eq!(
592            item.completion_criteria,
593            vec![
594                "All unit tests pass".to_string(),
595                "No clippy warnings".to_string()
596            ]
597        );
598    }
599
600    #[test]
601    fn task_list_from_args_with_existing_preserves_ids_on_reorder() {
602        let existing = TaskList {
603            session_id: "session_3".to_string(),
604            title: "Task List".to_string(),
605            items: vec![
606                TaskItem {
607                    id: "task_10".to_string(),
608                    description: "First task".to_string(),
609                    status: TaskItemStatus::Pending,
610                    depends_on: Vec::new(),
611                    notes: "original".to_string(),
612                    ..TaskItem::default()
613                },
614                TaskItem {
615                    id: "task_11".to_string(),
616                    description: "Second task".to_string(),
617                    status: TaskItemStatus::Pending,
618                    depends_on: Vec::new(),
619                    notes: "original".to_string(),
620                    ..TaskItem::default()
621                },
622            ],
623            created_at: chrono::Utc::now(),
624            updated_at: chrono::Utc::now(),
625        };
626
627        let list = TaskTool::task_list_from_args_with_existing(
628            &json!({
629                "tasks": [
630                    { "content": "Second task", "status": "in_progress" },
631                    { "content": "First task", "status": "pending" }
632                ]
633            }),
634            "session_3",
635            Some(&existing),
636            None,
637        )
638        .expect("ids should be preserved");
639
640        assert_eq!(list.items[0].id, "task_11");
641        assert_eq!(list.items[1].id, "task_10");
642    }
643
644    #[test]
645    fn task_list_from_args_with_existing_accepts_explicit_ids() {
646        let list = TaskTool::task_list_from_args(
647            &json!({
648                "tasks": [
649                    { "id": "task_42", "content": "Stable id task", "status": "pending" },
650                    { "taskId": "custom-2", "content": "Custom id alias", "status": "in_progress" }
651                ]
652            }),
653            "session_4",
654        )
655        .expect("explicit ids should parse");
656
657        assert_eq!(list.items[0].id, "task_42");
658        assert_eq!(list.items[1].id, "custom-2");
659    }
660
661    #[test]
662    fn task_list_from_args_accepts_id_and_task_id_when_same() {
663        let list = TaskTool::task_list_from_args(
664            &json!({
665                "tasks": [
666                    {
667                        "id": "task_100",
668                        "taskId": "task_100",
669                        "content": "Same identifier aliases",
670                        "status": "pending"
671                    }
672                ]
673            }),
674            "session_same_id_alias",
675        )
676        .expect("matching id/taskId should be accepted");
677
678        assert_eq!(list.items.len(), 1);
679        assert_eq!(list.items[0].id, "task_100");
680    }
681
682    #[test]
683    fn task_list_from_args_rejects_conflicting_id_and_task_id() {
684        let err = TaskTool::task_list_from_args(
685            &json!({
686                "tasks": [
687                    {
688                        "id": "task_100",
689                        "taskId": "task_101",
690                        "content": "Conflicting identifier aliases",
691                        "status": "pending"
692                    }
693                ]
694            }),
695            "session_conflicting_id_alias",
696        )
697        .expect_err("conflicting id/taskId should be rejected");
698
699        assert!(matches!(
700            err,
701            ToolError::InvalidArguments(message)
702                if message.contains("Conflicting task identifiers")
703        ));
704    }
705
706    #[test]
707    fn task_list_from_args_rejects_duplicate_explicit_ids() {
708        let err = TaskTool::task_list_from_args(
709            &json!({
710                "tasks": [
711                    { "id": "task_dup", "content": "One", "status": "pending" },
712                    { "id": "task_dup", "content": "Two", "status": "pending" }
713                ]
714            }),
715            "session_5",
716        )
717        .expect_err("duplicate explicit ids must fail");
718
719        assert!(matches!(
720            err,
721            ToolError::InvalidArguments(message) if message.contains("Duplicate task id")
722        ));
723    }
724
725    #[test]
726    fn task_list_from_args_with_existing_reuses_positional_ids_when_descriptions_change() {
727        let existing = TaskList {
728            session_id: "session_6".to_string(),
729            title: "Task List".to_string(),
730            items: vec![
731                TaskItem {
732                    id: "task_20".to_string(),
733                    description: "Old first".to_string(),
734                    status: TaskItemStatus::Pending,
735                    notes: "old".to_string(),
736                    ..TaskItem::default()
737                },
738                TaskItem {
739                    id: "task_21".to_string(),
740                    description: "Old second".to_string(),
741                    status: TaskItemStatus::Pending,
742                    notes: "old".to_string(),
743                    ..TaskItem::default()
744                },
745            ],
746            created_at: chrono::Utc::now(),
747            updated_at: chrono::Utc::now(),
748        };
749
750        let list = TaskTool::task_list_from_args_with_existing(
751            &json!({
752                "tasks": [
753                    { "content": "Renamed first", "status": "in_progress" },
754                    { "content": "Renamed second", "status": "pending" }
755                ]
756            }),
757            "session_6",
758            Some(&existing),
759            None,
760        )
761        .expect("positional ids should be reused when item count is unchanged");
762
763        assert_eq!(list.items[0].id, "task_20");
764        assert_eq!(list.items[1].id, "task_21");
765    }
766
767    #[test]
768    fn task_list_from_args_completion_gate_keeps_task_in_progress_when_criteria_unmet() {
769        let list = TaskTool::task_list_from_args(
770            &json!({
771                "tasks": [
772                    {
773                        "content": "Release package",
774                        "status": "completed",
775                        "completionCriteria": ["tests pass", "docs updated"],
776                        "criteriaMet": ["c1"]
777                    }
778                ]
779            }),
780            "session_7",
781        )
782        .expect("task list should parse");
783
784        assert_eq!(list.items[0].status, TaskItemStatus::InProgress);
785        assert!(list.items[0]
786            .notes
787            .contains("Completion criteria not fully met"));
788    }
789
790    #[test]
791    fn task_list_from_args_completion_gate_allows_completed_when_criteria_met() {
792        let list = TaskTool::task_list_from_args(
793            &json!({
794                "tasks": [
795                    {
796                        "content": "Release package",
797                        "status": "completed",
798                        "completionCriteria": ["tests pass", "docs updated"],
799                        "criteriaMet": ["c1", "criterion_2"]
800                    }
801                ]
802            }),
803            "session_8",
804        )
805        .expect("task list should parse");
806
807        assert_eq!(list.items[0].status, TaskItemStatus::Completed);
808    }
809
810    #[test]
811    fn task_list_from_args_with_existing_rejects_ambiguous_rewrite_when_lengths_change() {
812        let existing = TaskList {
813            session_id: "session_9".to_string(),
814            title: "Task List".to_string(),
815            items: vec![
816                TaskItem {
817                    id: "task_30".to_string(),
818                    description: "Keep me".to_string(),
819                    status: TaskItemStatus::Pending,
820                    ..TaskItem::default()
821                },
822                TaskItem {
823                    id: "task_31".to_string(),
824                    description: "Remove me".to_string(),
825                    status: TaskItemStatus::Pending,
826                    ..TaskItem::default()
827                },
828            ],
829            created_at: chrono::Utc::now(),
830            updated_at: chrono::Utc::now(),
831        };
832
833        let err = TaskTool::task_list_from_args_with_existing(
834            &json!({
835                "tasks": [
836                    { "content": "Brand new replacement", "status": "pending" }
837                ]
838            }),
839            "session_9",
840            Some(&existing),
841            None,
842        )
843        .expect_err("ambiguous rewrite should be rejected");
844
845        assert!(matches!(
846            err,
847            ToolError::InvalidArguments(message) if message.contains("Ambiguous task ID assignment")
848        ));
849    }
850
851    #[test]
852    fn task_list_from_args_with_existing_allows_additions_after_existing_are_matched() {
853        let existing = TaskList {
854            session_id: "session_10".to_string(),
855            title: "Task List".to_string(),
856            items: vec![
857                TaskItem {
858                    id: "task_40".to_string(),
859                    description: "First".to_string(),
860                    status: TaskItemStatus::Pending,
861                    ..TaskItem::default()
862                },
863                TaskItem {
864                    id: "task_41".to_string(),
865                    description: "Second".to_string(),
866                    status: TaskItemStatus::Pending,
867                    ..TaskItem::default()
868                },
869            ],
870            created_at: chrono::Utc::now(),
871            updated_at: chrono::Utc::now(),
872        };
873
874        let list = TaskTool::task_list_from_args_with_existing(
875            &json!({
876                "tasks": [
877                    { "content": "First", "status": "pending" },
878                    { "content": "Second", "status": "pending" },
879                    { "content": "Third", "status": "pending" }
880                ]
881            }),
882            "session_10",
883            Some(&existing),
884            None,
885        )
886        .expect("adding new items after matching existing ids should be allowed");
887
888        assert_eq!(list.items[0].id, "task_40");
889        assert_eq!(list.items[1].id, "task_41");
890        assert_eq!(list.items[2].id, "task_42");
891    }
892}