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}