use async_trait::async_trait;
use bamboo_agent_core::{Tool, ToolError, ToolResult};
use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
use bamboo_domain::{TaskPhase, TaskPriority};
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::collections::HashSet;
#[derive(Debug, Deserialize)]
struct TaskArgsRaw {
tasks: Vec<TaskWriteItem>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct TaskWriteItem {
#[serde(default)]
id: Option<String>,
#[serde(default, rename = "taskId")]
task_id: Option<String>,
content: String,
status: String,
#[serde(rename = "activeForm")]
active_form: Option<String>,
#[serde(default, alias = "dependsOn")]
depends_on: Vec<String>,
#[serde(default, alias = "parentId")]
parent_id: Option<String>,
#[serde(default)]
phase: Option<TaskPhase>,
#[serde(default)]
priority: Option<TaskPriority>,
#[serde(default, rename = "completionCriteria", alias = "completion_criteria")]
completion_criteria: Vec<String>,
#[serde(default, rename = "criteriaMet", alias = "criteria_met")]
criteria_met: Vec<String>,
}
fn normalize_required_text(value: Option<String>, field_name: &str) -> Result<String, ToolError> {
let Some(value) = value else {
return Err(ToolError::InvalidArguments(format!(
"{field_name} must be non-empty"
)));
};
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ToolError::InvalidArguments(format!(
"{field_name} must be non-empty"
)));
}
Ok(trimmed.to_string())
}
fn normalize_optional_text(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn normalize_string_list(values: Vec<String>) -> Vec<String> {
let mut deduped = HashSet::new();
let mut normalized = Vec::new();
for value in values {
let trimmed = value.trim();
if trimmed.is_empty() {
continue;
}
if deduped.insert(trimmed.to_string()) {
normalized.push(trimmed.to_string());
}
}
normalized
}
fn parse_requested_task_id(
id: Option<String>,
task_id: Option<String>,
) -> Result<Option<String>, ToolError> {
let id = normalize_optional_text(id);
let task_id = normalize_optional_text(task_id);
match (id, task_id) {
(Some(id), Some(task_id)) if id != task_id => Err(ToolError::InvalidArguments(format!(
"Conflicting task identifiers in tasks[]: id='{}' does not match taskId='{}'",
id, task_id
))),
(Some(id), Some(_)) => Ok(Some(id)),
(Some(id), None) => Ok(Some(id)),
(None, Some(task_id)) => Ok(Some(task_id)),
(None, None) => Ok(None),
}
}
fn normalize_criterion(value: &str) -> Option<String> {
let normalized = value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_lowercase();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
fn parse_criterion_ref(value: &str) -> Option<usize> {
let trimmed = value.trim().to_ascii_lowercase();
let as_c_ref = trimmed
.strip_prefix("criterion_")
.or_else(|| trimmed.strip_prefix("criterion-"))
.or_else(|| trimmed.strip_prefix('c'));
if let Some(raw_index) = as_c_ref {
return raw_index.parse::<usize>().ok().filter(|index| *index > 0);
}
None
}
fn missing_completion_criteria(required: &[String], criteria_met: &[String]) -> Vec<String> {
let mut required_lookup = HashMap::new();
for (index, criterion) in required.iter().enumerate() {
if let Some(normalized) = normalize_criterion(criterion) {
required_lookup.insert(normalized, index + 1);
}
}
let mut met_refs = HashSet::new();
for criterion in criteria_met {
if let Some(index) = parse_criterion_ref(criterion) {
met_refs.insert(index);
continue;
}
if let Some(normalized) = normalize_criterion(criterion) {
if let Some(index) = required_lookup.get(&normalized).copied() {
met_refs.insert(index);
}
}
}
required
.iter()
.enumerate()
.filter_map(|(index, criterion)| {
if met_refs.contains(&(index + 1)) {
None
} else {
Some(criterion.trim().to_string())
}
})
.collect()
}
fn parse_numeric_task_id(value: &str) -> Option<u64> {
value
.strip_prefix("task_")
.and_then(|suffix| suffix.parse::<u64>().ok())
}
fn next_generated_task_id(next_counter: &mut u64, assigned_ids: &HashSet<String>) -> String {
loop {
*next_counter = next_counter.saturating_add(1);
let candidate = format!("task_{}", *next_counter);
if !assigned_ids.contains(&candidate) {
return candidate;
}
}
}
fn find_reusable_task_id(
description: &str,
existing_items: &[TaskItem],
used_existing_ids: &HashSet<String>,
assigned_ids: &HashSet<String>,
) -> Option<String> {
existing_items
.iter()
.find(|item| {
item.description == description
&& !used_existing_ids.contains(&item.id)
&& !assigned_ids.contains(&item.id)
})
.map(|item| item.id.clone())
}
fn find_next_existing_id_by_position(
position: usize,
existing_items: &[TaskItem],
used_existing_ids: &HashSet<String>,
assigned_ids: &HashSet<String>,
) -> Option<String> {
existing_items
.get(position)
.map(|item| item.id.clone())
.filter(|id| !used_existing_ids.contains(id) && !assigned_ids.contains(id))
}
pub struct TaskTool;
impl TaskTool {
pub fn new() -> Self {
Self
}
pub fn task_list_from_args(
args: &serde_json::Value,
session_id: &str,
) -> Result<TaskList, ToolError> {
Self::task_list_from_args_with_existing(args, session_id, None, None)
}
pub fn task_list_from_args_with_existing(
args: &serde_json::Value,
session_id: &str,
existing: Option<&TaskList>,
default_phase: Option<TaskPhase>,
) -> Result<TaskList, ToolError> {
let parsed: TaskArgsRaw = serde_json::from_value(args.clone())
.map_err(|e| ToolError::InvalidArguments(format!("Invalid Task args: {e}")))?;
let incoming_count = parsed.tasks.len();
let items_source = if parsed.tasks.is_empty() {
return Err(ToolError::InvalidArguments(
"Task requires a non-empty `tasks` array".to_string(),
));
} else {
parsed.tasks
};
let existing_items = existing
.map(|task_list| task_list.items.clone())
.unwrap_or_default();
let mut used_existing_ids = HashSet::new();
let mut assigned_ids = HashSet::new();
let preserve_positional_ids = existing
.map(|task_list| task_list.items.len() == incoming_count)
.unwrap_or(false);
let mut generated_new_ids = false;
let mut next_generated_counter = existing_items
.iter()
.filter_map(|item| parse_numeric_task_id(&item.id))
.max()
.unwrap_or(0);
let mut items = Vec::with_capacity(items_source.len());
for task in items_source {
let description = normalize_required_text(Some(task.content), "tasks[].content")?;
let status = match task.status.as_str() {
"pending" => TaskItemStatus::Pending,
"in_progress" => TaskItemStatus::InProgress,
"completed" => TaskItemStatus::Completed,
"blocked" => TaskItemStatus::Blocked,
_ => {
return Err(ToolError::InvalidArguments(format!(
"Invalid task status '{}' (expected pending/in_progress/completed/blocked)",
task.status
)))
}
};
let requested_id = parse_requested_task_id(task.id, task.task_id)?;
let task_id = if let Some(requested_id) = requested_id {
if assigned_ids.contains(&requested_id) {
return Err(ToolError::InvalidArguments(format!(
"Duplicate task id '{}' in tasks[] payload",
requested_id
)));
}
requested_id
} else if let Some(reused_id) = find_reusable_task_id(
&description,
&existing_items,
&used_existing_ids,
&assigned_ids,
) {
reused_id
} else if preserve_positional_ids {
find_next_existing_id_by_position(
items.len(),
&existing_items,
&used_existing_ids,
&assigned_ids,
)
.unwrap_or_else(|| {
generated_new_ids = true;
next_generated_task_id(&mut next_generated_counter, &assigned_ids)
})
} else {
generated_new_ids = true;
next_generated_task_id(&mut next_generated_counter, &assigned_ids)
};
assigned_ids.insert(task_id.clone());
let active_form = normalize_optional_text(task.active_form);
let existing_item = existing_items
.iter()
.find(|item| item.id == task_id)
.cloned();
if existing_item.is_some() {
used_existing_ids.insert(task_id.clone());
}
let notes = active_form
.clone()
.or_else(|| {
existing_item
.as_ref()
.map(|item| item.notes.trim().to_string())
.filter(|notes| !notes.is_empty())
})
.unwrap_or_else(|| description.clone());
let mut depends_on = normalize_string_list(task.depends_on);
depends_on.retain(|dependency_id| dependency_id != &task_id);
let mut completion_criteria = normalize_string_list(task.completion_criteria);
completion_criteria.retain(|criterion| !criterion.is_empty());
let criteria_met = normalize_string_list(task.criteria_met);
let mut parent_id = normalize_optional_text(task.parent_id);
if parent_id.as_deref() == Some(task_id.as_str()) {
parent_id = None;
}
let mut effective_status = status;
let mut gate_note: Option<String> = None;
if matches!(effective_status, TaskItemStatus::Completed)
&& !completion_criteria.is_empty()
{
let missing = missing_completion_criteria(&completion_criteria, &criteria_met);
if !missing.is_empty() {
effective_status = TaskItemStatus::InProgress;
gate_note = Some(format!(
"Completion criteria not fully met; keeping task in_progress. Missing: {}",
missing.join(" | ")
));
}
}
let mut item = existing_item.unwrap_or_default();
item.id = task_id;
item.description = description.clone();
if item.status != effective_status {
item.transition_to(effective_status, gate_note.as_deref(), None);
}
item.depends_on = depends_on;
item.notes = if let Some(gate_note) = gate_note {
if notes.trim().is_empty() {
gate_note
} else {
format!("{notes}\n{gate_note}")
}
} else {
notes
};
item.active_form = active_form;
item.parent_id = parent_id;
item.phase = task
.phase
.unwrap_or_else(|| default_phase.as_ref().cloned().unwrap_or_default());
item.priority = task.priority.unwrap_or_default();
item.completion_criteria = completion_criteria;
items.push(item);
}
if !existing_items.is_empty()
&& generated_new_ids
&& used_existing_ids.len() < existing_items.len()
{
return Err(ToolError::InvalidArguments(
"Ambiguous task ID assignment during full-list rewrite. Include stable `id`/`taskId` for retained tasks when adding/removing tasks in the same update."
.to_string(),
));
}
Ok(TaskList {
session_id: session_id.to_string(),
title: "Task List".to_string(),
items,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
})
}
}
impl Default for TaskTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for TaskTool {
fn name(&self) -> &str {
"Task"
}
fn description(&self) -> &str {
"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."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"tasks": {
"type": "array",
"description": "Canonical task items for the shared task list.",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"taskId": { "type": "string" },
"content": { "type": "string", "minLength": 1 },
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed", "blocked"]
},
"activeForm": { "type": "string" },
"dependsOn": {
"type": "array",
"items": { "type": "string" }
},
"parentId": { "type": "string" },
"phase": {
"type": "string",
"enum": ["planning", "execution", "verification", "handoff"]
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "critical"]
},
"completionCriteria": {
"type": "array",
"items": { "type": "string" }
},
"criteriaMet": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["content", "status"],
"additionalProperties": false
}
}
},
"required": ["tasks"],
"additionalProperties": false
})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
let parsed: TaskArgsRaw = serde_json::from_value(args)
.map_err(|e| ToolError::InvalidArguments(format!("Invalid Task args: {e}")))?;
let count = parsed.tasks.len();
if count == 0 {
return Err(ToolError::InvalidArguments(
"Task requires a non-empty `tasks` array".to_string(),
));
}
Ok(ToolResult {
success: true,
result: format!("Task list updated with {count} items"),
display_preference: Some("Default".to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn task_execute_accepts_tasks_payload() {
let tool = TaskTool::new();
let result = tool
.execute(json!({
"tasks": [
{
"content": "Summarize parser entrypoints",
"status": "in_progress",
"activeForm": "Summarizing parser entrypoints"
}
]
}))
.await
.expect("Task should validate payload");
assert!(result.success);
assert!(result.result.contains("1 items"));
}
#[tokio::test]
async fn task_execute_rejects_empty_payload() {
let tool = TaskTool::new();
let err = tool
.execute(json!({}))
.await
.expect_err("Task should reject empty payload");
assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("tasks")));
}
#[tokio::test]
async fn task_execute_rejects_legacy_todos_field() {
let tool = TaskTool::new();
let err = tool
.execute(json!({
"todos": [
{
"content": "Legacy path",
"status": "pending"
}
]
}))
.await
.expect_err("Task should reject legacy todos field");
assert!(
matches!(err, ToolError::InvalidArguments(msg) if msg.contains("Invalid Task args"))
);
}
#[test]
fn task_list_from_args_supports_blocked_status() {
let list = TaskTool::task_list_from_args(
&json!({
"tasks": [
{
"content": "Waiting on API token",
"status": "blocked",
"activeForm": "Blocked by missing API token"
}
]
}),
"session_1",
)
.expect("blocked status should be accepted");
assert_eq!(list.session_id, "session_1");
assert_eq!(list.items.len(), 1);
assert_eq!(list.items[0].status, TaskItemStatus::Blocked);
}
#[test]
fn task_list_from_args_parses_structured_fields() {
let list = TaskTool::task_list_from_args(
&json!({
"tasks": [
{
"content": "Implement migration path",
"status": "in_progress",
"activeForm": "Implementing migration path",
"dependsOn": ["task_99", "task_99", " "],
"parentId": "epic_1",
"phase": "verification",
"priority": "high",
"completionCriteria": [
"All unit tests pass",
"No clippy warnings",
"All unit tests pass"
]
}
]
}),
"session_2",
)
.expect("structured fields should parse");
let item = &list.items[0];
assert_eq!(item.id, "task_1");
assert_eq!(
item.active_form.as_deref(),
Some("Implementing migration path")
);
assert_eq!(item.depends_on, vec!["task_99".to_string()]);
assert_eq!(item.parent_id.as_deref(), Some("epic_1"));
assert_eq!(item.phase, TaskPhase::Verification);
assert_eq!(item.priority, TaskPriority::High);
assert_eq!(
item.completion_criteria,
vec![
"All unit tests pass".to_string(),
"No clippy warnings".to_string()
]
);
}
#[test]
fn task_list_from_args_with_existing_preserves_ids_on_reorder() {
let existing = TaskList {
session_id: "session_3".to_string(),
title: "Task List".to_string(),
items: vec![
TaskItem {
id: "task_10".to_string(),
description: "First task".to_string(),
status: TaskItemStatus::Pending,
depends_on: Vec::new(),
notes: "original".to_string(),
..TaskItem::default()
},
TaskItem {
id: "task_11".to_string(),
description: "Second task".to_string(),
status: TaskItemStatus::Pending,
depends_on: Vec::new(),
notes: "original".to_string(),
..TaskItem::default()
},
],
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let list = TaskTool::task_list_from_args_with_existing(
&json!({
"tasks": [
{ "content": "Second task", "status": "in_progress" },
{ "content": "First task", "status": "pending" }
]
}),
"session_3",
Some(&existing),
None,
)
.expect("ids should be preserved");
assert_eq!(list.items[0].id, "task_11");
assert_eq!(list.items[1].id, "task_10");
}
#[test]
fn task_list_from_args_with_existing_accepts_explicit_ids() {
let list = TaskTool::task_list_from_args(
&json!({
"tasks": [
{ "id": "task_42", "content": "Stable id task", "status": "pending" },
{ "taskId": "custom-2", "content": "Custom id alias", "status": "in_progress" }
]
}),
"session_4",
)
.expect("explicit ids should parse");
assert_eq!(list.items[0].id, "task_42");
assert_eq!(list.items[1].id, "custom-2");
}
#[test]
fn task_list_from_args_accepts_id_and_task_id_when_same() {
let list = TaskTool::task_list_from_args(
&json!({
"tasks": [
{
"id": "task_100",
"taskId": "task_100",
"content": "Same identifier aliases",
"status": "pending"
}
]
}),
"session_same_id_alias",
)
.expect("matching id/taskId should be accepted");
assert_eq!(list.items.len(), 1);
assert_eq!(list.items[0].id, "task_100");
}
#[test]
fn task_list_from_args_rejects_conflicting_id_and_task_id() {
let err = TaskTool::task_list_from_args(
&json!({
"tasks": [
{
"id": "task_100",
"taskId": "task_101",
"content": "Conflicting identifier aliases",
"status": "pending"
}
]
}),
"session_conflicting_id_alias",
)
.expect_err("conflicting id/taskId should be rejected");
assert!(matches!(
err,
ToolError::InvalidArguments(message)
if message.contains("Conflicting task identifiers")
));
}
#[test]
fn task_list_from_args_rejects_duplicate_explicit_ids() {
let err = TaskTool::task_list_from_args(
&json!({
"tasks": [
{ "id": "task_dup", "content": "One", "status": "pending" },
{ "id": "task_dup", "content": "Two", "status": "pending" }
]
}),
"session_5",
)
.expect_err("duplicate explicit ids must fail");
assert!(matches!(
err,
ToolError::InvalidArguments(message) if message.contains("Duplicate task id")
));
}
#[test]
fn task_list_from_args_with_existing_reuses_positional_ids_when_descriptions_change() {
let existing = TaskList {
session_id: "session_6".to_string(),
title: "Task List".to_string(),
items: vec![
TaskItem {
id: "task_20".to_string(),
description: "Old first".to_string(),
status: TaskItemStatus::Pending,
notes: "old".to_string(),
..TaskItem::default()
},
TaskItem {
id: "task_21".to_string(),
description: "Old second".to_string(),
status: TaskItemStatus::Pending,
notes: "old".to_string(),
..TaskItem::default()
},
],
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let list = TaskTool::task_list_from_args_with_existing(
&json!({
"tasks": [
{ "content": "Renamed first", "status": "in_progress" },
{ "content": "Renamed second", "status": "pending" }
]
}),
"session_6",
Some(&existing),
None,
)
.expect("positional ids should be reused when item count is unchanged");
assert_eq!(list.items[0].id, "task_20");
assert_eq!(list.items[1].id, "task_21");
}
#[test]
fn task_list_from_args_completion_gate_keeps_task_in_progress_when_criteria_unmet() {
let list = TaskTool::task_list_from_args(
&json!({
"tasks": [
{
"content": "Release package",
"status": "completed",
"completionCriteria": ["tests pass", "docs updated"],
"criteriaMet": ["c1"]
}
]
}),
"session_7",
)
.expect("task list should parse");
assert_eq!(list.items[0].status, TaskItemStatus::InProgress);
assert!(list.items[0]
.notes
.contains("Completion criteria not fully met"));
}
#[test]
fn task_list_from_args_completion_gate_allows_completed_when_criteria_met() {
let list = TaskTool::task_list_from_args(
&json!({
"tasks": [
{
"content": "Release package",
"status": "completed",
"completionCriteria": ["tests pass", "docs updated"],
"criteriaMet": ["c1", "criterion_2"]
}
]
}),
"session_8",
)
.expect("task list should parse");
assert_eq!(list.items[0].status, TaskItemStatus::Completed);
}
#[test]
fn task_list_from_args_with_existing_rejects_ambiguous_rewrite_when_lengths_change() {
let existing = TaskList {
session_id: "session_9".to_string(),
title: "Task List".to_string(),
items: vec![
TaskItem {
id: "task_30".to_string(),
description: "Keep me".to_string(),
status: TaskItemStatus::Pending,
..TaskItem::default()
},
TaskItem {
id: "task_31".to_string(),
description: "Remove me".to_string(),
status: TaskItemStatus::Pending,
..TaskItem::default()
},
],
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let err = TaskTool::task_list_from_args_with_existing(
&json!({
"tasks": [
{ "content": "Brand new replacement", "status": "pending" }
]
}),
"session_9",
Some(&existing),
None,
)
.expect_err("ambiguous rewrite should be rejected");
assert!(matches!(
err,
ToolError::InvalidArguments(message) if message.contains("Ambiguous task ID assignment")
));
}
#[test]
fn task_list_from_args_with_existing_allows_additions_after_existing_are_matched() {
let existing = TaskList {
session_id: "session_10".to_string(),
title: "Task List".to_string(),
items: vec![
TaskItem {
id: "task_40".to_string(),
description: "First".to_string(),
status: TaskItemStatus::Pending,
..TaskItem::default()
},
TaskItem {
id: "task_41".to_string(),
description: "Second".to_string(),
status: TaskItemStatus::Pending,
..TaskItem::default()
},
],
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let list = TaskTool::task_list_from_args_with_existing(
&json!({
"tasks": [
{ "content": "First", "status": "pending" },
{ "content": "Second", "status": "pending" },
{ "content": "Third", "status": "pending" }
]
}),
"session_10",
Some(&existing),
None,
)
.expect("adding new items after matching existing ids should be allowed");
assert_eq!(list.items[0].id, "task_40");
assert_eq!(list.items[1].id, "task_41");
assert_eq!(list.items[2].id, "task_42");
}
}