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