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