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