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)
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    ) -> Result<TaskList, ToolError> {
216        let parsed: TaskArgsRaw = serde_json::from_value(args.clone())
217            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Task args: {e}")))?;
218        let incoming_count = parsed.tasks.len();
219
220        let items_source = if parsed.tasks.is_empty() {
221            return Err(ToolError::InvalidArguments(
222                "Task requires a non-empty `tasks` array".to_string(),
223            ));
224        } else {
225            parsed.tasks
226        };
227
228        let existing_items = existing
229            .map(|task_list| task_list.items.clone())
230            .unwrap_or_default();
231        let mut used_existing_ids = HashSet::new();
232        let mut assigned_ids = HashSet::new();
233        let preserve_positional_ids = existing
234            .map(|task_list| task_list.items.len() == incoming_count)
235            .unwrap_or(false);
236        let mut generated_new_ids = false;
237        let mut next_generated_counter = existing_items
238            .iter()
239            .filter_map(|item| parse_numeric_task_id(&item.id))
240            .max()
241            .unwrap_or(0);
242
243        let mut items = Vec::with_capacity(items_source.len());
244        for task in items_source {
245            let description = normalize_required_text(Some(task.content), "tasks[].content")?;
246            let status = match task.status.as_str() {
247                "pending" => TaskItemStatus::Pending,
248                "in_progress" => TaskItemStatus::InProgress,
249                "completed" => TaskItemStatus::Completed,
250                "blocked" => TaskItemStatus::Blocked,
251                _ => {
252                    return Err(ToolError::InvalidArguments(format!(
253                        "Invalid task status '{}' (expected pending/in_progress/completed/blocked)",
254                        task.status
255                    )))
256                }
257            };
258
259            let requested_id = parse_requested_task_id(task.id, task.task_id)?;
260            let task_id = if let Some(requested_id) = requested_id {
261                if assigned_ids.contains(&requested_id) {
262                    return Err(ToolError::InvalidArguments(format!(
263                        "Duplicate task id '{}' in tasks[] payload",
264                        requested_id
265                    )));
266                }
267                requested_id
268            } else if let Some(reused_id) = find_reusable_task_id(
269                &description,
270                &existing_items,
271                &used_existing_ids,
272                &assigned_ids,
273            ) {
274                reused_id
275            } else if preserve_positional_ids {
276                find_next_existing_id_by_position(
277                    items.len(),
278                    &existing_items,
279                    &used_existing_ids,
280                    &assigned_ids,
281                )
282                .unwrap_or_else(|| {
283                    generated_new_ids = true;
284                    next_generated_task_id(&mut next_generated_counter, &assigned_ids)
285                })
286            } else {
287                generated_new_ids = true;
288                next_generated_task_id(&mut next_generated_counter, &assigned_ids)
289            };
290
291            assigned_ids.insert(task_id.clone());
292            let active_form = normalize_optional_text(task.active_form);
293            let existing_item = existing_items
294                .iter()
295                .find(|item| item.id == task_id)
296                .cloned();
297            if existing_item.is_some() {
298                used_existing_ids.insert(task_id.clone());
299            }
300            let notes = active_form
301                .clone()
302                .or_else(|| {
303                    existing_item
304                        .as_ref()
305                        .map(|item| item.notes.trim().to_string())
306                        .filter(|notes| !notes.is_empty())
307                })
308                .unwrap_or_else(|| description.clone());
309            let mut depends_on = normalize_string_list(task.depends_on);
310            depends_on.retain(|dependency_id| dependency_id != &task_id);
311            let mut completion_criteria = normalize_string_list(task.completion_criteria);
312            completion_criteria.retain(|criterion| !criterion.is_empty());
313            let criteria_met = normalize_string_list(task.criteria_met);
314            let mut parent_id = normalize_optional_text(task.parent_id);
315            if parent_id.as_deref() == Some(task_id.as_str()) {
316                parent_id = None;
317            }
318
319            let mut effective_status = status;
320            let mut gate_note: Option<String> = None;
321            if matches!(effective_status, TaskItemStatus::Completed)
322                && !completion_criteria.is_empty()
323            {
324                let missing = missing_completion_criteria(&completion_criteria, &criteria_met);
325                if !missing.is_empty() {
326                    effective_status = TaskItemStatus::InProgress;
327                    gate_note = Some(format!(
328                        "Completion criteria not fully met; keeping task in_progress. Missing: {}",
329                        missing.join(" | ")
330                    ));
331                }
332            }
333
334            let mut item = existing_item.unwrap_or_default();
335            item.id = task_id;
336            item.description = description.clone();
337            if item.status != effective_status {
338                item.transition_to(effective_status, gate_note.as_deref(), None);
339            }
340            item.depends_on = depends_on;
341            item.notes = if let Some(gate_note) = gate_note {
342                if notes.trim().is_empty() {
343                    gate_note
344                } else {
345                    format!("{notes}\n{gate_note}")
346                }
347            } else {
348                notes
349            };
350            item.active_form = active_form;
351            item.parent_id = parent_id;
352            item.phase = task.phase.unwrap_or_default();
353            item.priority = task.priority.unwrap_or_default();
354            item.completion_criteria = completion_criteria;
355
356            items.push(item);
357        }
358
359        if !existing_items.is_empty()
360            && generated_new_ids
361            && used_existing_ids.len() < existing_items.len()
362        {
363            return Err(ToolError::InvalidArguments(
364                "Ambiguous task ID assignment during full-list rewrite. Include stable `id`/`taskId` for retained tasks when adding/removing tasks in the same update."
365                    .to_string(),
366            ));
367        }
368
369        Ok(TaskList {
370            session_id: session_id.to_string(),
371            title: "Task List".to_string(),
372            items,
373            created_at: chrono::Utc::now(),
374            updated_at: chrono::Utc::now(),
375        })
376    }
377}
378
379impl Default for TaskTool {
380    fn default() -> Self {
381        Self::new()
382    }
383}
384
385#[async_trait]
386impl Tool for TaskTool {
387    fn name(&self) -> &str {
388        "Task"
389    }
390
391    fn description(&self) -> &str {
392        "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."
393    }
394
395    fn parameters_schema(&self) -> serde_json::Value {
396        json!({
397            "type": "object",
398            "properties": {
399                "tasks": {
400                    "type": "array",
401                    "description": "Canonical task items for the shared task list.",
402                    "items": {
403                        "type": "object",
404                        "properties": {
405                            "id": { "type": "string" },
406                            "taskId": { "type": "string" },
407                            "content": { "type": "string", "minLength": 1 },
408                            "status": {
409                                "type": "string",
410                                "enum": ["pending", "in_progress", "completed", "blocked"]
411                            },
412                            "activeForm": { "type": "string" },
413                            "dependsOn": {
414                                "type": "array",
415                                "items": { "type": "string" }
416                            },
417                            "parentId": { "type": "string" },
418                            "phase": {
419                                "type": "string",
420                                "enum": ["planning", "execution", "verification", "handoff"]
421                            },
422                            "priority": {
423                                "type": "string",
424                                "enum": ["low", "medium", "high", "critical"]
425                            },
426                            "completionCriteria": {
427                                "type": "array",
428                                "items": { "type": "string" }
429                            },
430                            "criteriaMet": {
431                                "type": "array",
432                                "items": { "type": "string" }
433                            }
434                        },
435                        "required": ["content", "status"],
436                        "additionalProperties": false
437                    }
438                }
439            },
440            "required": ["tasks"],
441            "additionalProperties": false
442        })
443    }
444
445    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
446        let parsed: TaskArgsRaw = serde_json::from_value(args)
447            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Task args: {e}")))?;
448        let count = parsed.tasks.len();
449        if count == 0 {
450            return Err(ToolError::InvalidArguments(
451                "Task requires a non-empty `tasks` array".to_string(),
452            ));
453        }
454
455        Ok(ToolResult {
456            success: true,
457            result: format!("Task list updated with {count} items"),
458            display_preference: Some("Default".to_string()),
459        })
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[tokio::test]
468    async fn task_execute_accepts_tasks_payload() {
469        let tool = TaskTool::new();
470        let result = tool
471            .execute(json!({
472                "tasks": [
473                    {
474                        "content": "Summarize parser entrypoints",
475                        "status": "in_progress",
476                        "activeForm": "Summarizing parser entrypoints"
477                    }
478                ]
479            }))
480            .await
481            .expect("Task should validate payload");
482
483        assert!(result.success);
484        assert!(result.result.contains("1 items"));
485    }
486
487    #[tokio::test]
488    async fn task_execute_rejects_empty_payload() {
489        let tool = TaskTool::new();
490        let err = tool
491            .execute(json!({}))
492            .await
493            .expect_err("Task should reject empty payload");
494
495        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("tasks")));
496    }
497
498    #[tokio::test]
499    async fn task_execute_rejects_legacy_todos_field() {
500        let tool = TaskTool::new();
501        let err = tool
502            .execute(json!({
503                "todos": [
504                    {
505                        "content": "Legacy path",
506                        "status": "pending"
507                    }
508                ]
509            }))
510            .await
511            .expect_err("Task should reject legacy todos field");
512
513        assert!(
514            matches!(err, ToolError::InvalidArguments(msg) if msg.contains("Invalid Task args"))
515        );
516    }
517
518    #[test]
519    fn task_list_from_args_supports_blocked_status() {
520        let list = TaskTool::task_list_from_args(
521            &json!({
522                "tasks": [
523                    {
524                        "content": "Waiting on API token",
525                        "status": "blocked",
526                        "activeForm": "Blocked by missing API token"
527                    }
528                ]
529            }),
530            "session_1",
531        )
532        .expect("blocked status should be accepted");
533
534        assert_eq!(list.session_id, "session_1");
535        assert_eq!(list.items.len(), 1);
536        assert_eq!(list.items[0].status, TaskItemStatus::Blocked);
537    }
538
539    #[test]
540    fn task_list_from_args_parses_structured_fields() {
541        let list = TaskTool::task_list_from_args(
542            &json!({
543                "tasks": [
544                    {
545                        "content": "Implement migration path",
546                        "status": "in_progress",
547                        "activeForm": "Implementing migration path",
548                        "dependsOn": ["task_99", "task_99", " "],
549                        "parentId": "epic_1",
550                        "phase": "verification",
551                        "priority": "high",
552                        "completionCriteria": [
553                            "All unit tests pass",
554                            "No clippy warnings",
555                            "All unit tests pass"
556                        ]
557                    }
558                ]
559            }),
560            "session_2",
561        )
562        .expect("structured fields should parse");
563
564        let item = &list.items[0];
565        assert_eq!(item.id, "task_1");
566        assert_eq!(
567            item.active_form.as_deref(),
568            Some("Implementing migration path")
569        );
570        assert_eq!(item.depends_on, vec!["task_99".to_string()]);
571        assert_eq!(item.parent_id.as_deref(), Some("epic_1"));
572        assert_eq!(item.phase, TaskPhase::Verification);
573        assert_eq!(item.priority, TaskPriority::High);
574        assert_eq!(
575            item.completion_criteria,
576            vec![
577                "All unit tests pass".to_string(),
578                "No clippy warnings".to_string()
579            ]
580        );
581    }
582
583    #[test]
584    fn task_list_from_args_with_existing_preserves_ids_on_reorder() {
585        let existing = TaskList {
586            session_id: "session_3".to_string(),
587            title: "Task List".to_string(),
588            items: vec![
589                TaskItem {
590                    id: "task_10".to_string(),
591                    description: "First task".to_string(),
592                    status: TaskItemStatus::Pending,
593                    depends_on: Vec::new(),
594                    notes: "original".to_string(),
595                    ..TaskItem::default()
596                },
597                TaskItem {
598                    id: "task_11".to_string(),
599                    description: "Second task".to_string(),
600                    status: TaskItemStatus::Pending,
601                    depends_on: Vec::new(),
602                    notes: "original".to_string(),
603                    ..TaskItem::default()
604                },
605            ],
606            created_at: chrono::Utc::now(),
607            updated_at: chrono::Utc::now(),
608        };
609
610        let list = TaskTool::task_list_from_args_with_existing(
611            &json!({
612                "tasks": [
613                    { "content": "Second task", "status": "in_progress" },
614                    { "content": "First task", "status": "pending" }
615                ]
616            }),
617            "session_3",
618            Some(&existing),
619        )
620        .expect("ids should be preserved");
621
622        assert_eq!(list.items[0].id, "task_11");
623        assert_eq!(list.items[1].id, "task_10");
624    }
625
626    #[test]
627    fn task_list_from_args_with_existing_accepts_explicit_ids() {
628        let list = TaskTool::task_list_from_args(
629            &json!({
630                "tasks": [
631                    { "id": "task_42", "content": "Stable id task", "status": "pending" },
632                    { "taskId": "custom-2", "content": "Custom id alias", "status": "in_progress" }
633                ]
634            }),
635            "session_4",
636        )
637        .expect("explicit ids should parse");
638
639        assert_eq!(list.items[0].id, "task_42");
640        assert_eq!(list.items[1].id, "custom-2");
641    }
642
643    #[test]
644    fn task_list_from_args_accepts_id_and_task_id_when_same() {
645        let list = TaskTool::task_list_from_args(
646            &json!({
647                "tasks": [
648                    {
649                        "id": "task_100",
650                        "taskId": "task_100",
651                        "content": "Same identifier aliases",
652                        "status": "pending"
653                    }
654                ]
655            }),
656            "session_same_id_alias",
657        )
658        .expect("matching id/taskId should be accepted");
659
660        assert_eq!(list.items.len(), 1);
661        assert_eq!(list.items[0].id, "task_100");
662    }
663
664    #[test]
665    fn task_list_from_args_rejects_conflicting_id_and_task_id() {
666        let err = TaskTool::task_list_from_args(
667            &json!({
668                "tasks": [
669                    {
670                        "id": "task_100",
671                        "taskId": "task_101",
672                        "content": "Conflicting identifier aliases",
673                        "status": "pending"
674                    }
675                ]
676            }),
677            "session_conflicting_id_alias",
678        )
679        .expect_err("conflicting id/taskId should be rejected");
680
681        assert!(matches!(
682            err,
683            ToolError::InvalidArguments(message)
684                if message.contains("Conflicting task identifiers")
685        ));
686    }
687
688    #[test]
689    fn task_list_from_args_rejects_duplicate_explicit_ids() {
690        let err = TaskTool::task_list_from_args(
691            &json!({
692                "tasks": [
693                    { "id": "task_dup", "content": "One", "status": "pending" },
694                    { "id": "task_dup", "content": "Two", "status": "pending" }
695                ]
696            }),
697            "session_5",
698        )
699        .expect_err("duplicate explicit ids must fail");
700
701        assert!(matches!(
702            err,
703            ToolError::InvalidArguments(message) if message.contains("Duplicate task id")
704        ));
705    }
706
707    #[test]
708    fn task_list_from_args_with_existing_reuses_positional_ids_when_descriptions_change() {
709        let existing = TaskList {
710            session_id: "session_6".to_string(),
711            title: "Task List".to_string(),
712            items: vec![
713                TaskItem {
714                    id: "task_20".to_string(),
715                    description: "Old first".to_string(),
716                    status: TaskItemStatus::Pending,
717                    notes: "old".to_string(),
718                    ..TaskItem::default()
719                },
720                TaskItem {
721                    id: "task_21".to_string(),
722                    description: "Old second".to_string(),
723                    status: TaskItemStatus::Pending,
724                    notes: "old".to_string(),
725                    ..TaskItem::default()
726                },
727            ],
728            created_at: chrono::Utc::now(),
729            updated_at: chrono::Utc::now(),
730        };
731
732        let list = TaskTool::task_list_from_args_with_existing(
733            &json!({
734                "tasks": [
735                    { "content": "Renamed first", "status": "in_progress" },
736                    { "content": "Renamed second", "status": "pending" }
737                ]
738            }),
739            "session_6",
740            Some(&existing),
741        )
742        .expect("positional ids should be reused when item count is unchanged");
743
744        assert_eq!(list.items[0].id, "task_20");
745        assert_eq!(list.items[1].id, "task_21");
746    }
747
748    #[test]
749    fn task_list_from_args_completion_gate_keeps_task_in_progress_when_criteria_unmet() {
750        let list = TaskTool::task_list_from_args(
751            &json!({
752                "tasks": [
753                    {
754                        "content": "Release package",
755                        "status": "completed",
756                        "completionCriteria": ["tests pass", "docs updated"],
757                        "criteriaMet": ["c1"]
758                    }
759                ]
760            }),
761            "session_7",
762        )
763        .expect("task list should parse");
764
765        assert_eq!(list.items[0].status, TaskItemStatus::InProgress);
766        assert!(list.items[0]
767            .notes
768            .contains("Completion criteria not fully met"));
769    }
770
771    #[test]
772    fn task_list_from_args_completion_gate_allows_completed_when_criteria_met() {
773        let list = TaskTool::task_list_from_args(
774            &json!({
775                "tasks": [
776                    {
777                        "content": "Release package",
778                        "status": "completed",
779                        "completionCriteria": ["tests pass", "docs updated"],
780                        "criteriaMet": ["c1", "criterion_2"]
781                    }
782                ]
783            }),
784            "session_8",
785        )
786        .expect("task list should parse");
787
788        assert_eq!(list.items[0].status, TaskItemStatus::Completed);
789    }
790
791    #[test]
792    fn task_list_from_args_with_existing_rejects_ambiguous_rewrite_when_lengths_change() {
793        let existing = TaskList {
794            session_id: "session_9".to_string(),
795            title: "Task List".to_string(),
796            items: vec![
797                TaskItem {
798                    id: "task_30".to_string(),
799                    description: "Keep me".to_string(),
800                    status: TaskItemStatus::Pending,
801                    ..TaskItem::default()
802                },
803                TaskItem {
804                    id: "task_31".to_string(),
805                    description: "Remove me".to_string(),
806                    status: TaskItemStatus::Pending,
807                    ..TaskItem::default()
808                },
809            ],
810            created_at: chrono::Utc::now(),
811            updated_at: chrono::Utc::now(),
812        };
813
814        let err = TaskTool::task_list_from_args_with_existing(
815            &json!({
816                "tasks": [
817                    { "content": "Brand new replacement", "status": "pending" }
818                ]
819            }),
820            "session_9",
821            Some(&existing),
822        )
823        .expect_err("ambiguous rewrite should be rejected");
824
825        assert!(matches!(
826            err,
827            ToolError::InvalidArguments(message) if message.contains("Ambiguous task ID assignment")
828        ));
829    }
830
831    #[test]
832    fn task_list_from_args_with_existing_allows_additions_after_existing_are_matched() {
833        let existing = TaskList {
834            session_id: "session_10".to_string(),
835            title: "Task List".to_string(),
836            items: vec![
837                TaskItem {
838                    id: "task_40".to_string(),
839                    description: "First".to_string(),
840                    status: TaskItemStatus::Pending,
841                    ..TaskItem::default()
842                },
843                TaskItem {
844                    id: "task_41".to_string(),
845                    description: "Second".to_string(),
846                    status: TaskItemStatus::Pending,
847                    ..TaskItem::default()
848                },
849            ],
850            created_at: chrono::Utc::now(),
851            updated_at: chrono::Utc::now(),
852        };
853
854        let list = TaskTool::task_list_from_args_with_existing(
855            &json!({
856                "tasks": [
857                    { "content": "First", "status": "pending" },
858                    { "content": "Second", "status": "pending" },
859                    { "content": "Third", "status": "pending" }
860                ]
861            }),
862            "session_10",
863            Some(&existing),
864        )
865        .expect("adding new items after matching existing ids should be allowed");
866
867        assert_eq!(list.items[0].id, "task_40");
868        assert_eq!(list.items[1].id, "task_41");
869        assert_eq!(list.items[2].id, "task_42");
870    }
871}