Skip to main content

bamboo_tools/tools/
task.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, 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 execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
449        let parsed: TaskArgsRaw = serde_json::from_value(args)
450            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Task args: {e}")))?;
451        let count = parsed.tasks.len();
452        if count == 0 {
453            return Err(ToolError::InvalidArguments(
454                "Task requires a non-empty `tasks` array".to_string(),
455            ));
456        }
457
458        Ok(ToolResult {
459            success: true,
460            result: format!("Task list updated with {count} items"),
461            display_preference: Some("Default".to_string()),
462        })
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[tokio::test]
471    async fn task_execute_accepts_tasks_payload() {
472        let tool = TaskTool::new();
473        let result = tool
474            .execute(json!({
475                "tasks": [
476                    {
477                        "content": "Summarize parser entrypoints",
478                        "status": "in_progress",
479                        "activeForm": "Summarizing parser entrypoints"
480                    }
481                ]
482            }))
483            .await
484            .expect("Task should validate payload");
485
486        assert!(result.success);
487        assert!(result.result.contains("1 items"));
488    }
489
490    #[tokio::test]
491    async fn task_execute_rejects_empty_payload() {
492        let tool = TaskTool::new();
493        let err = tool
494            .execute(json!({}))
495            .await
496            .expect_err("Task should reject empty payload");
497
498        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("tasks")));
499    }
500
501    #[tokio::test]
502    async fn task_execute_rejects_legacy_todos_field() {
503        let tool = TaskTool::new();
504        let err = tool
505            .execute(json!({
506                "todos": [
507                    {
508                        "content": "Legacy path",
509                        "status": "pending"
510                    }
511                ]
512            }))
513            .await
514            .expect_err("Task should reject legacy todos field");
515
516        assert!(
517            matches!(err, ToolError::InvalidArguments(msg) if msg.contains("Invalid Task args"))
518        );
519    }
520
521    #[test]
522    fn task_list_from_args_supports_blocked_status() {
523        let list = TaskTool::task_list_from_args(
524            &json!({
525                "tasks": [
526                    {
527                        "content": "Waiting on API token",
528                        "status": "blocked",
529                        "activeForm": "Blocked by missing API token"
530                    }
531                ]
532            }),
533            "session_1",
534        )
535        .expect("blocked status should be accepted");
536
537        assert_eq!(list.session_id, "session_1");
538        assert_eq!(list.items.len(), 1);
539        assert_eq!(list.items[0].status, TaskItemStatus::Blocked);
540    }
541
542    #[test]
543    fn task_list_from_args_parses_structured_fields() {
544        let list = TaskTool::task_list_from_args(
545            &json!({
546                "tasks": [
547                    {
548                        "content": "Implement migration path",
549                        "status": "in_progress",
550                        "activeForm": "Implementing migration path",
551                        "dependsOn": ["task_99", "task_99", " "],
552                        "parentId": "epic_1",
553                        "phase": "verification",
554                        "priority": "high",
555                        "completionCriteria": [
556                            "All unit tests pass",
557                            "No clippy warnings",
558                            "All unit tests pass"
559                        ]
560                    }
561                ]
562            }),
563            "session_2",
564        )
565        .expect("structured fields should parse");
566
567        let item = &list.items[0];
568        assert_eq!(item.id, "task_1");
569        assert_eq!(
570            item.active_form.as_deref(),
571            Some("Implementing migration path")
572        );
573        assert_eq!(item.depends_on, vec!["task_99".to_string()]);
574        assert_eq!(item.parent_id.as_deref(), Some("epic_1"));
575        assert_eq!(item.phase, TaskPhase::Verification);
576        assert_eq!(item.priority, TaskPriority::High);
577        assert_eq!(
578            item.completion_criteria,
579            vec![
580                "All unit tests pass".to_string(),
581                "No clippy warnings".to_string()
582            ]
583        );
584    }
585
586    #[test]
587    fn task_list_from_args_with_existing_preserves_ids_on_reorder() {
588        let existing = TaskList {
589            session_id: "session_3".to_string(),
590            title: "Task List".to_string(),
591            items: vec![
592                TaskItem {
593                    id: "task_10".to_string(),
594                    description: "First task".to_string(),
595                    status: TaskItemStatus::Pending,
596                    depends_on: Vec::new(),
597                    notes: "original".to_string(),
598                    ..TaskItem::default()
599                },
600                TaskItem {
601                    id: "task_11".to_string(),
602                    description: "Second task".to_string(),
603                    status: TaskItemStatus::Pending,
604                    depends_on: Vec::new(),
605                    notes: "original".to_string(),
606                    ..TaskItem::default()
607                },
608            ],
609            created_at: chrono::Utc::now(),
610            updated_at: chrono::Utc::now(),
611        };
612
613        let list = TaskTool::task_list_from_args_with_existing(
614            &json!({
615                "tasks": [
616                    { "content": "Second task", "status": "in_progress" },
617                    { "content": "First task", "status": "pending" }
618                ]
619            }),
620            "session_3",
621            Some(&existing),
622            None,
623        )
624        .expect("ids should be preserved");
625
626        assert_eq!(list.items[0].id, "task_11");
627        assert_eq!(list.items[1].id, "task_10");
628    }
629
630    #[test]
631    fn task_list_from_args_with_existing_accepts_explicit_ids() {
632        let list = TaskTool::task_list_from_args(
633            &json!({
634                "tasks": [
635                    { "id": "task_42", "content": "Stable id task", "status": "pending" },
636                    { "taskId": "custom-2", "content": "Custom id alias", "status": "in_progress" }
637                ]
638            }),
639            "session_4",
640        )
641        .expect("explicit ids should parse");
642
643        assert_eq!(list.items[0].id, "task_42");
644        assert_eq!(list.items[1].id, "custom-2");
645    }
646
647    #[test]
648    fn task_list_from_args_accepts_id_and_task_id_when_same() {
649        let list = TaskTool::task_list_from_args(
650            &json!({
651                "tasks": [
652                    {
653                        "id": "task_100",
654                        "taskId": "task_100",
655                        "content": "Same identifier aliases",
656                        "status": "pending"
657                    }
658                ]
659            }),
660            "session_same_id_alias",
661        )
662        .expect("matching id/taskId should be accepted");
663
664        assert_eq!(list.items.len(), 1);
665        assert_eq!(list.items[0].id, "task_100");
666    }
667
668    #[test]
669    fn task_list_from_args_rejects_conflicting_id_and_task_id() {
670        let err = TaskTool::task_list_from_args(
671            &json!({
672                "tasks": [
673                    {
674                        "id": "task_100",
675                        "taskId": "task_101",
676                        "content": "Conflicting identifier aliases",
677                        "status": "pending"
678                    }
679                ]
680            }),
681            "session_conflicting_id_alias",
682        )
683        .expect_err("conflicting id/taskId should be rejected");
684
685        assert!(matches!(
686            err,
687            ToolError::InvalidArguments(message)
688                if message.contains("Conflicting task identifiers")
689        ));
690    }
691
692    #[test]
693    fn task_list_from_args_rejects_duplicate_explicit_ids() {
694        let err = TaskTool::task_list_from_args(
695            &json!({
696                "tasks": [
697                    { "id": "task_dup", "content": "One", "status": "pending" },
698                    { "id": "task_dup", "content": "Two", "status": "pending" }
699                ]
700            }),
701            "session_5",
702        )
703        .expect_err("duplicate explicit ids must fail");
704
705        assert!(matches!(
706            err,
707            ToolError::InvalidArguments(message) if message.contains("Duplicate task id")
708        ));
709    }
710
711    #[test]
712    fn task_list_from_args_with_existing_reuses_positional_ids_when_descriptions_change() {
713        let existing = TaskList {
714            session_id: "session_6".to_string(),
715            title: "Task List".to_string(),
716            items: vec![
717                TaskItem {
718                    id: "task_20".to_string(),
719                    description: "Old first".to_string(),
720                    status: TaskItemStatus::Pending,
721                    notes: "old".to_string(),
722                    ..TaskItem::default()
723                },
724                TaskItem {
725                    id: "task_21".to_string(),
726                    description: "Old second".to_string(),
727                    status: TaskItemStatus::Pending,
728                    notes: "old".to_string(),
729                    ..TaskItem::default()
730                },
731            ],
732            created_at: chrono::Utc::now(),
733            updated_at: chrono::Utc::now(),
734        };
735
736        let list = TaskTool::task_list_from_args_with_existing(
737            &json!({
738                "tasks": [
739                    { "content": "Renamed first", "status": "in_progress" },
740                    { "content": "Renamed second", "status": "pending" }
741                ]
742            }),
743            "session_6",
744            Some(&existing),
745            None,
746        )
747        .expect("positional ids should be reused when item count is unchanged");
748
749        assert_eq!(list.items[0].id, "task_20");
750        assert_eq!(list.items[1].id, "task_21");
751    }
752
753    #[test]
754    fn task_list_from_args_completion_gate_keeps_task_in_progress_when_criteria_unmet() {
755        let list = TaskTool::task_list_from_args(
756            &json!({
757                "tasks": [
758                    {
759                        "content": "Release package",
760                        "status": "completed",
761                        "completionCriteria": ["tests pass", "docs updated"],
762                        "criteriaMet": ["c1"]
763                    }
764                ]
765            }),
766            "session_7",
767        )
768        .expect("task list should parse");
769
770        assert_eq!(list.items[0].status, TaskItemStatus::InProgress);
771        assert!(list.items[0]
772            .notes
773            .contains("Completion criteria not fully met"));
774    }
775
776    #[test]
777    fn task_list_from_args_completion_gate_allows_completed_when_criteria_met() {
778        let list = TaskTool::task_list_from_args(
779            &json!({
780                "tasks": [
781                    {
782                        "content": "Release package",
783                        "status": "completed",
784                        "completionCriteria": ["tests pass", "docs updated"],
785                        "criteriaMet": ["c1", "criterion_2"]
786                    }
787                ]
788            }),
789            "session_8",
790        )
791        .expect("task list should parse");
792
793        assert_eq!(list.items[0].status, TaskItemStatus::Completed);
794    }
795
796    #[test]
797    fn task_list_from_args_with_existing_rejects_ambiguous_rewrite_when_lengths_change() {
798        let existing = TaskList {
799            session_id: "session_9".to_string(),
800            title: "Task List".to_string(),
801            items: vec![
802                TaskItem {
803                    id: "task_30".to_string(),
804                    description: "Keep me".to_string(),
805                    status: TaskItemStatus::Pending,
806                    ..TaskItem::default()
807                },
808                TaskItem {
809                    id: "task_31".to_string(),
810                    description: "Remove me".to_string(),
811                    status: TaskItemStatus::Pending,
812                    ..TaskItem::default()
813                },
814            ],
815            created_at: chrono::Utc::now(),
816            updated_at: chrono::Utc::now(),
817        };
818
819        let err = TaskTool::task_list_from_args_with_existing(
820            &json!({
821                "tasks": [
822                    { "content": "Brand new replacement", "status": "pending" }
823                ]
824            }),
825            "session_9",
826            Some(&existing),
827            None,
828        )
829        .expect_err("ambiguous rewrite should be rejected");
830
831        assert!(matches!(
832            err,
833            ToolError::InvalidArguments(message) if message.contains("Ambiguous task ID assignment")
834        ));
835    }
836
837    #[test]
838    fn task_list_from_args_with_existing_allows_additions_after_existing_are_matched() {
839        let existing = TaskList {
840            session_id: "session_10".to_string(),
841            title: "Task List".to_string(),
842            items: vec![
843                TaskItem {
844                    id: "task_40".to_string(),
845                    description: "First".to_string(),
846                    status: TaskItemStatus::Pending,
847                    ..TaskItem::default()
848                },
849                TaskItem {
850                    id: "task_41".to_string(),
851                    description: "Second".to_string(),
852                    status: TaskItemStatus::Pending,
853                    ..TaskItem::default()
854                },
855            ],
856            created_at: chrono::Utc::now(),
857            updated_at: chrono::Utc::now(),
858        };
859
860        let list = TaskTool::task_list_from_args_with_existing(
861            &json!({
862                "tasks": [
863                    { "content": "First", "status": "pending" },
864                    { "content": "Second", "status": "pending" },
865                    { "content": "Third", "status": "pending" }
866                ]
867            }),
868            "session_10",
869            Some(&existing),
870            None,
871        )
872        .expect("adding new items after matching existing ids should be allowed");
873
874        assert_eq!(list.items[0].id, "task_40");
875        assert_eq!(list.items[1].id, "task_41");
876        assert_eq!(list.items[2].id, "task_42");
877    }
878}