recursive-agent 0.8.3

A Rust coding-agent platform: kernel, tools, multi-agent orchestration, HTTP API, MCP, and TUI
Documentation
//! `task_list` tool — list all tasks in the registry, with optional filters.

use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::Arc;

use crate::error::Result;
use crate::llm::ToolSpec;
use crate::tasks::{TaskRegistry, TaskStatus};
use crate::tools::{Tool, ToolSideEffect};

pub struct TaskListTool {
    registry: Arc<TaskRegistry>,
}

impl TaskListTool {
    pub fn new(registry: Arc<TaskRegistry>) -> Self {
        Self { registry }
    }
}

#[async_trait]
impl Tool for TaskListTool {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "task_list".into(),
            description: concat!(
                "List all tasks in the in-memory task registry. Optionally ",
                "filter by status, team, or teammate name."
            )
            .into(),
            parameters: json!({
                "type": "object",
                "properties": {
                    "status": {
                        "type": "string",
                        "description": "Optional: filter by task status (running, completed, failed, stopped).",
                        "enum": ["running", "completed", "failed", "stopped"]
                    },
                    "team": {
                        "type": "string",
                        "description": "Optional: only include tasks belonging to this team."
                    },
                    "name": {
                        "type": "string",
                        "description": "Optional: only include tasks for this teammate name."
                    }
                }
            }),
        }
    }

    fn side_effect_class(&self) -> ToolSideEffect {
        ToolSideEffect::ReadOnly
    }

    async fn execute(&self, arguments: Value) -> Result<String> {
        let status_filter =
            arguments
                .get("status")
                .and_then(|v| v.as_str())
                .and_then(|s| match s {
                    "running" => Some(TaskStatus::Running),
                    "completed" => Some(TaskStatus::Completed),
                    "failed" => Some(TaskStatus::Failed),
                    "stopped" => Some(TaskStatus::Stopped),
                    _ => None,
                });
        let team_filter = arguments.get("team").and_then(|v| v.as_str());
        let name_filter = arguments.get("name").and_then(|v| v.as_str());

        let mut tasks = self.registry.list().await;
        let mut out_lines: Vec<(String, TaskStatus, String, String, String)> = Vec::new();
        for t in tasks.drain(..) {
            let s = t.status().await;
            if let Some(sf) = status_filter {
                if s != sf {
                    continue;
                }
            }
            if let Some(team) = team_filter {
                if t.team != team {
                    continue;
                }
            }
            if let Some(name) = name_filter {
                if t.name != name {
                    continue;
                }
            }
            out_lines.push((
                t.id.to_string(),
                s,
                t.team.clone(),
                t.name.clone(),
                t.description.clone(),
            ));
        }

        if out_lines.is_empty() {
            return Ok("(no tasks)".to_string());
        }

        let mut out = format!("{} task(s):\n", out_lines.len());
        for (id, s, team, name, desc) in out_lines {
            out.push_str(&format!(
                "  {id} [{:>9}] team={} name={} desc={}\n",
                s.as_str(),
                if team.is_empty() { "-" } else { &team },
                if name.is_empty() { "-" } else { &name },
                truncate(&desc, 80),
            ));
        }
        Ok(out)
    }
}

fn truncate(s: &str, max: usize) -> String {
    if s.len() <= max {
        s.to_string()
    } else {
        let mut end = max;
        while end > 0 && !s.is_char_boundary(end) {
            end -= 1;
        }
        format!("{}…", &s[..end])
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tasks::TaskState;

    #[tokio::test]
    async fn list_empty() {
        let reg = Arc::new(TaskRegistry::new());
        let tool = TaskListTool::new(reg);
        let out = tool.execute(json!({})).await.unwrap();
        assert!(out.contains("(no tasks)"));
    }

    #[tokio::test]
    async fn list_with_filter() {
        let reg = Arc::new(TaskRegistry::new());
        let (s1, _) = TaskState::new("t1", "alpha", "r");
        let (s2, _) = TaskState::new("t2", "beta", "c");
        let (s3, _) = TaskState::new("t3", "alpha", "r");
        reg.register(s1).await;
        reg.register(s2).await;
        reg.register(s3).await;

        let tool = TaskListTool::new(reg.clone());
        let all = tool.execute(json!({})).await.unwrap();
        assert!(all.contains("3 task(s)"));
        let alpha = tool.execute(json!({ "team": "alpha" })).await.unwrap();
        assert!(alpha.contains("2 task(s)"));
        let alpha_r = tool
            .execute(json!({ "team": "alpha", "name": "r" }))
            .await
            .unwrap();
        assert!(alpha_r.contains("2 task(s)"));
    }

    // ── truncate() targeted tests ────────────────────────────────────────────

    #[test]
    fn truncate_short_is_unchanged() {
        // kills function-level replacement
        assert_eq!(truncate("abc", 10), "abc");
    }

    #[test]
    fn truncate_at_exact_max_is_not_truncated() {
        // kills `replace <= with <` in `if s.len() <= max`
        assert_eq!(truncate("abc", 3), "abc");
    }

    #[test]
    fn truncate_over_max_appends_ellipsis() {
        // kills else-branch mutations
        let out = truncate("Hello World", 5);
        assert!(out.ends_with('…'), "must end with ellipsis; got: {out}");
    }

    #[tokio::test]
    async fn status_filter_returns_only_matching_status() {
        // kills `if s != sf { continue }` guard-flip mutation (status filter)
        let reg = Arc::new(TaskRegistry::new());
        let (s1, _) = TaskState::new("running task", "", "");
        let (s2, id2) = TaskState::new("completed task", "", "");
        reg.register(s1).await;
        let arc2 = reg.register(s2).await;
        arc2.mark_completed("done".into()).await;

        let tool = TaskListTool::new(reg);
        let out = tool.execute(json!({ "status": "running" })).await.unwrap();
        assert!(
            out.contains("1 task(s)"),
            "status=running must only return running tasks; got: {out}"
        );
        assert!(
            !out.contains(id2.to_string().as_str()),
            "completed task must not appear in running filter"
        );
    }

    #[tokio::test]
    async fn empty_team_and_name_shown_as_dash() {
        // kills `if team.is_empty() { "-" } else { &team }` guard-flip mutations
        let reg = Arc::new(TaskRegistry::new());
        let (s, _) = TaskState::new("task with no team or name", "", "");
        reg.register(s).await;
        let tool = TaskListTool::new(reg);
        let out = tool.execute(json!({})).await.unwrap();
        // Both team and name are empty → both should appear as "-" in the output
        let dash_count = out.matches("team=- name=-").count();
        assert_eq!(
            dash_count, 1,
            "empty team and name must display as '-'; got: {out}"
        );
    }
}