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}