fabro/lib/components/fabro-agent/src/todo_tools.rs
2026-07-25 13:15:57 -04:00

1240 lines
44 KiB
Rust

//! Model-facing todo / task tools.
//!
//! Three surfaces share one engine ([`TodoRuntime`]):
//!
//! - [`make_update_plan_tool`] — Codex-compatible OpenAI `update_plan`.
//! - [`make_todo_list_tool`] — Kimi Code-compatible whole-list `TodoList`.
//! - [`make_task_create_tool`] / [`make_task_update_tool`] /
//! [`make_task_get_tool`] / [`make_task_list_tool`] — Claude task tools.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt::Write;
use std::str::FromStr;
use std::sync::Arc;
use fabro_llm::types::ToolDefinition;
use fabro_types::{TodoListKind, TodoProjection, TodoStatus, TodoUpdatedProps};
use serde_json::Value;
use strum::{EnumString, IntoStaticStr};
use crate::todo_runtime::TodoRuntime;
use crate::tool_registry::{RegisteredTool, ToolContext, ToolSource};
/// Compute a session-scoped todo-list ID. Returns an error the model can see
/// when a tool is invoked without an active session.
fn session_todo_scope(
ctx: &ToolContext,
kind: TodoListKind,
tool_name: &str,
) -> Result<String, String> {
ctx.session_id
.as_ref()
.map(|session_id| kind.list_id(session_id))
.ok_or_else(|| format!("{tool_name} requires an active session"))
}
/// Compute the Anthropic task scope
/// (`anthropic_tasks:<root_session_id>`). Falls back to `session_id` when
/// the root is not bound; errors if neither is set.
fn anthropic_task_scope(ctx: &ToolContext) -> Result<String, String> {
ctx.root_session_id
.as_ref()
.or(ctx.session_id.as_ref())
.map(|sid| TodoListKind::AnthropicTasks.list_id(sid))
.ok_or_else(|| "task tools require an active session".to_string())
}
/// Parse a wire status string into a [`TodoStatus`], optionally rejecting
/// `"deleted"` (OpenAI's `update_plan` does not accept deletions).
fn parse_status(value: &str, allow_deleted: bool) -> Result<TodoStatus, String> {
let status = TodoStatus::from_str(value).map_err(|_| {
if allow_deleted {
format!("Invalid status `{value}` (expected pending|in_progress|completed|deleted)")
} else {
format!("Invalid status `{value}` (expected pending|in_progress|completed)")
}
})?;
if !allow_deleted && status == TodoStatus::Deleted {
return Err(format!(
"Invalid status `{value}` (expected pending|in_progress|completed)"
));
}
Ok(status)
}
const TASK_CREATE_DESCRIPTION: &str = "Create pending tasks in the current session. \
Use concise subjects, descriptions, optional activeForm text, and metadata. Check \
TaskList first to avoid duplicate tasks.";
const TASK_UPDATE_DESCRIPTION: &str = "Update an existing task's status, text, owner, \
metadata, or dependencies. Valid statuses are pending, in_progress, completed, and \
deleted. After completing a task, call TaskList to find newly unblocked work.";
const TASK_LIST_DESCRIPTION: &str = "List tasks for the current session, including \
status, owner, and blocking dependencies. Use TaskGet with a taskId for full \
description and dependency details.";
const TASK_GET_DESCRIPTION: &str = "Get one task by taskId, including subject, status, \
description, owner, blockedBy, and blocks.";
/// Deterministic todo id derived from `<list_id>::<text>`. Whole-list tools
/// identify an item by its exact text, so unchanged entries preserve identity.
fn todo_text_id(list_id: &str, text: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(list_id.as_bytes());
hasher.update(b"\x00");
hasher.update(text.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(16);
for byte in &digest[..8] {
let _ = write!(out, "{byte:02x}");
}
out
}
struct ReplacementTodo {
id: String,
subject: String,
status: TodoStatus,
}
fn reconcile_replacement_list(
runtime: &TodoRuntime,
ctx: &ToolContext,
kind: TodoListKind,
list_id: &str,
incoming: &[ReplacementTodo],
) {
let previous = runtime
.snapshot(list_id)
.map(|list| list.items)
.unwrap_or_default();
let previous_by_id: HashMap<&str, &TodoProjection> = previous
.iter()
.map(|todo| (todo.id.as_str(), todo))
.collect();
let incoming_ids: HashSet<&str> = incoming.iter().map(|todo| todo.id.as_str()).collect();
for todo in &previous {
if !incoming_ids.contains(todo.id.as_str()) {
runtime.delete(ctx, kind, list_id.to_string(), todo.id.clone());
}
}
for (index, todo) in incoming.iter().enumerate() {
let order = u32::try_from(index).unwrap_or(u32::MAX);
match previous_by_id.get(todo.id.as_str()) {
Some(previous)
if previous.status == todo.status
&& previous.order == order
&& previous.subject == todo.subject => {}
Some(_) => {
runtime.update(ctx, TodoUpdatedProps {
status: Some(todo.status),
order: Some(order),
subject: Some(todo.subject.clone()),
..TodoUpdatedProps::new(list_id, kind, &todo.id)
});
}
None => {
let mut projection =
TodoProjection::new(todo.id.clone(), order, todo.subject.clone());
projection.status = todo.status;
runtime.create(ctx, kind, list_id.to_string(), projection);
}
}
}
}
/// OpenAI `update_plan` tool. See plan summary for semantics.
#[must_use]
pub fn make_update_plan_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "update_plan".into(),
description: "Update the multi-step plan for the current task. Submit the entire \
plan; existing steps are reconciled by exact step text."
.into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"explanation": {
"type": "string",
"description": "Optional natural-language note about why the plan changed"
},
"plan": {
"type": "array",
"description": "Ordered list of plan steps, each with a status",
"items": {
"type": "object",
"properties": {
"step": {"type": "string"},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed"]
}
},
"required": ["step", "status"]
}
}
},
"required": ["plan"]
}),
},
executor: Arc::new(move |args, ctx| {
let runtime = runtime.clone();
Box::pin(async move {
let list_id = session_todo_scope(&ctx, TodoListKind::OpenAiPlan, "update_plan")?;
let plan = args
.get("plan")
.and_then(Value::as_array)
.ok_or_else(|| "Missing required parameter: plan".to_string())?;
// Parse incoming steps, precompute ids, and enforce step-text uniqueness.
let mut incoming = Vec::with_capacity(plan.len());
let mut seen_steps: HashSet<&str> = HashSet::with_capacity(plan.len());
for (index, entry) in plan.iter().enumerate() {
let step = entry
.get("step")
.and_then(Value::as_str)
.ok_or_else(|| format!("plan[{index}] is missing `step`"))?;
let status = entry
.get("status")
.and_then(Value::as_str)
.ok_or_else(|| format!("plan[{index}] is missing `status`"))?;
let status = parse_status(status, false)?;
if !seen_steps.insert(step) {
return Err(format!(
"Duplicate plan step `{step}` — step text must be unique"
));
}
incoming.push(ReplacementTodo {
id: todo_text_id(&list_id, step),
subject: step.to_string(),
status,
});
}
reconcile_replacement_list(
&runtime,
&ctx,
TodoListKind::OpenAiPlan,
&list_id,
&incoming,
);
Ok("Plan updated".to_string())
})
}),
source: ToolSource::Native,
}
}
#[derive(Clone, Copy, EnumString, IntoStaticStr)]
#[strum(serialize_all = "snake_case")]
enum KimiTodoStatus {
Pending,
InProgress,
#[strum(to_string = "done")]
Done,
}
impl From<KimiTodoStatus> for TodoStatus {
fn from(status: KimiTodoStatus) -> Self {
match status {
KimiTodoStatus::Pending => Self::Pending,
KimiTodoStatus::InProgress => Self::InProgress,
KimiTodoStatus::Done => Self::Completed,
}
}
}
impl From<TodoStatus> for KimiTodoStatus {
fn from(status: TodoStatus) -> Self {
match status {
TodoStatus::Pending => Self::Pending,
TodoStatus::InProgress => Self::InProgress,
TodoStatus::Completed | TodoStatus::Deleted => Self::Done,
}
}
}
/// Kimi Code spells the terminal status `done`; internally it is
/// [`TodoStatus::Completed`].
fn parse_kimi_status(value: &str) -> Result<TodoStatus, String> {
value
.parse::<KimiTodoStatus>()
.map(TodoStatus::from)
.map_err(|_| format!("Invalid status `{value}` (expected pending|in_progress|done)"))
}
fn kimi_status_name(status: TodoStatus) -> &'static str {
KimiTodoStatus::from(status).into()
}
fn render_kimi_todos<'a>(items: impl IntoIterator<Item = (TodoStatus, &'a str)>) -> String {
let mut items = items.into_iter().peekable();
if items.peek().is_none() {
return "The todo list is empty.".to_string();
}
let mut out = String::new();
for (status, subject) in items {
let _ = writeln!(out, "[{}] {subject}", kimi_status_name(status));
}
out.truncate(out.trim_end().len());
out
}
/// Kimi Code-compatible `TodoList`.
///
/// A single tool serves reads and writes, matching the surface Kimi models are
/// trained against: omit `todos` to read, pass `[]` to clear, pass a list to
/// replace the whole thing. Items carry only `title` and `status`, and the
/// terminal status is spelled `done`.
///
/// Reconciliation mirrors `update_plan` — items are identified by their text,
/// so a re-submitted list preserves identity for unchanged entries — and the
/// same [`TodoRuntime`] backs it, so projections and events are unchanged.
pub fn make_todo_list_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "TodoList".into(),
description: "Maintain a structured TODO list for the current task. Use it \
proactively for multi-step work. Pass `todos` to replace the entire \
list, omit `todos` to read the current list without changing it, and \
pass an empty array to clear it. Keep exactly one item `in_progress` \
while work is underway, and mark an item `done` as soon as it is \
finished rather than batching completions at the end."
.into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"todos": {
"type": "array",
"description": "The updated todo list. Omit to read the current list \
without making changes. Pass an empty array to clear the list.",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Short, actionable title for the todo."
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "done"],
"description": "Current status of the todo."
}
},
"required": ["title", "status"]
}
}
}
}),
},
executor: Arc::new(move |args, ctx| {
let runtime = runtime.clone();
Box::pin(async move {
let list_id = session_todo_scope(&ctx, TodoListKind::KimiTodos, "TodoList")?;
// Read mode: `todos` omitted entirely.
let Some(todos) = args.get("todos") else {
let items = runtime
.snapshot(&list_id)
.map(|l| l.items)
.unwrap_or_default();
return Ok(render_kimi_todos(
items
.iter()
.map(|todo| (todo.status, todo.subject.as_str())),
));
};
let todos = todos
.as_array()
.ok_or_else(|| "`todos` must be an array".to_string())?;
let mut incoming = Vec::with_capacity(todos.len());
let mut seen: HashSet<&str> = HashSet::with_capacity(todos.len());
for (index, entry) in todos.iter().enumerate() {
let title = entry
.get("title")
.and_then(Value::as_str)
.ok_or_else(|| format!("todos[{index}] is missing `title`"))?;
let status = entry
.get("status")
.and_then(Value::as_str)
.ok_or_else(|| format!("todos[{index}] is missing `status`"))?;
let status = parse_kimi_status(status)?;
if !seen.insert(title) {
return Err(format!("Duplicate todo `{title}` — titles must be unique"));
}
incoming.push(ReplacementTodo {
id: todo_text_id(&list_id, title),
subject: title.to_string(),
status,
});
}
reconcile_replacement_list(
&runtime,
&ctx,
TodoListKind::KimiTodos,
&list_id,
&incoming,
);
Ok(render_kimi_todos(
incoming
.iter()
.map(|todo| (todo.status, todo.subject.as_str())),
))
})
}),
source: ToolSource::Native,
}
}
fn optional_string(args: &Value, key: &str) -> Option<String> {
args.get(key)
.and_then(Value::as_str)
.map(ToString::to_string)
}
fn optional_string_vec(args: &Value, key: &str) -> Option<Vec<String>> {
args.get(key).and_then(Value::as_array).map(|values| {
values
.iter()
.filter_map(|v| v.as_str().map(ToString::to_string))
.collect()
})
}
fn metadata_map(args: &Value) -> BTreeMap<String, Value> {
args.get("metadata")
.and_then(Value::as_object)
.map(|map| {
map.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default()
}
fn append_task_refs(out: &mut String, label: &str, task_ids: &[String]) {
if task_ids.is_empty() {
return;
}
let _ = write!(out, "\n{label}: ");
for (index, task_id) in task_ids.iter().enumerate() {
if index > 0 {
out.push_str(", ");
}
let _ = write!(out, "#{task_id}");
}
}
fn format_task_details(todo: &TodoProjection) -> String {
let mut out = format!(
"Task #{}: {}\nStatus: {}\nDescription: {}",
todo.id, todo.subject, todo.status, todo.description
);
if let Some(owner) = todo.owner.as_ref() {
let _ = write!(out, "\nOwner: {owner}");
}
append_task_refs(&mut out, "Blocked by", &todo.blocked_by);
append_task_refs(&mut out, "Blocks", &todo.blocks);
out
}
#[must_use]
pub fn make_task_create_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "TaskCreate".into(),
description: TASK_CREATE_DESCRIPTION.into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"subject": {"type": "string"},
"description": {"type": "string"},
"activeForm": {"type": "string"},
"metadata": {"type": "object", "additionalProperties": true}
},
"required": ["subject", "description"]
}),
},
executor: Arc::new(move |args, ctx| {
let runtime = runtime.clone();
Box::pin(async move {
let list_id = anthropic_task_scope(&ctx)?;
let subject = args
.get("subject")
.and_then(Value::as_str)
.ok_or_else(|| "Missing required parameter: subject".to_string())?
.to_string();
let description = args
.get("description")
.and_then(Value::as_str)
.ok_or_else(|| "Missing required parameter: description".to_string())?
.to_string();
let task_id = runtime.next_task_id(&list_id);
let id_string = task_id.to_string();
let order = u32::try_from(task_id.saturating_sub(1)).unwrap_or(u32::MAX);
let mut projection = TodoProjection::new(id_string, order, subject.clone());
projection.description = description;
projection.active_form = optional_string(&args, "activeForm");
projection.metadata = metadata_map(&args);
runtime.create(&ctx, TodoListKind::AnthropicTasks, list_id, projection);
Ok(format!("Task #{task_id} created successfully: {subject}"))
})
}),
source: ToolSource::Native,
}
}
#[must_use]
pub fn make_task_update_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "TaskUpdate".into(),
description: TASK_UPDATE_DESCRIPTION.into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"taskId": {"type": "string"},
"subject": {"type": "string"},
"description": {"type": "string"},
"activeForm": {"type": "string"},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed", "deleted"]
},
"owner": {"type": "string"},
"addBlocks": {"type": "array", "items": {"type": "string"}},
"addBlockedBy": {"type": "array", "items": {"type": "string"}},
"metadata": {"type": "object", "additionalProperties": true}
},
"required": ["taskId"]
}),
},
executor: Arc::new(move |args, ctx| {
let runtime = runtime.clone();
Box::pin(async move {
let list_id = anthropic_task_scope(&ctx)?;
let task_id = args
.get("taskId")
.and_then(Value::as_str)
.ok_or_else(|| "Missing required parameter: taskId".to_string())?
.to_string();
let status = args
.get("status")
.and_then(Value::as_str)
.map(|s| parse_status(s, true))
.transpose()?;
let props = TodoUpdatedProps {
status,
subject: optional_string(&args, "subject"),
description: optional_string(&args, "description"),
active_form: args
.get("activeForm")
.map(|value| value.as_str().map(ToString::to_string)),
owner: args
.get("owner")
.map(|value| value.as_str().map(ToString::to_string)),
add_blocks: optional_string_vec(&args, "addBlocks"),
add_blocked_by: optional_string_vec(&args, "addBlockedBy"),
metadata_patch: metadata_map(&args),
..TodoUpdatedProps::new(&list_id, TodoListKind::AnthropicTasks, &task_id)
};
if runtime.update(&ctx, props) {
Ok(format!("Task #{task_id} updated"))
} else {
// Anthropic spec: missing task returns a non-error result.
Ok("Task not found".to_string())
}
})
}),
source: ToolSource::Native,
}
}
#[must_use]
pub fn make_task_get_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "TaskGet".into(),
description: TASK_GET_DESCRIPTION.into(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"taskId": {"type": "string"}
},
"required": ["taskId"]
}),
},
executor: Arc::new(move |args, ctx| {
let runtime = runtime.clone();
Box::pin(async move {
let list_id = anthropic_task_scope(&ctx)?;
let task_id = args
.get("taskId")
.and_then(Value::as_str)
.ok_or_else(|| "Missing required parameter: taskId".to_string())?;
let Some(snapshot) = runtime.snapshot(&list_id) else {
return Ok("Task not found".to_string());
};
let Some(todo) = snapshot.get(task_id) else {
return Ok("Task not found".to_string());
};
Ok(format_task_details(todo))
})
}),
source: ToolSource::Native,
}
}
#[must_use]
pub fn make_task_list_tool(runtime: Arc<TodoRuntime>) -> RegisteredTool {
RegisteredTool {
definition: ToolDefinition {
name: "TaskList".into(),
description: TASK_LIST_DESCRIPTION.into(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
},
executor: Arc::new(move |_args, ctx| {
let runtime = runtime.clone();
Box::pin(async move {
let list_id = anthropic_task_scope(&ctx)?;
let snapshot = runtime.snapshot(&list_id);
let items: &[TodoProjection] = snapshot.as_ref().map_or(&[], |list| &list.items);
if items.is_empty() {
return Ok("No tasks found".to_string());
}
// Pre-build a status lookup so the per-row blocker filter is
// O(B) rather than O(B * N).
let status_by_id: HashMap<&str, TodoStatus> =
items.iter().map(|t| (t.id.as_str(), t.status)).collect();
let mut out = String::new();
for todo in items {
let _ = write!(out, "#{} [{}] {}", todo.id, todo.status, todo.subject);
if let Some(owner) = todo.owner.as_ref() {
let _ = write!(out, " (owner: {owner})");
}
// Uncompleted blockers only — Claude's convention.
let mut blockers = todo.blocked_by.iter().filter(|id| {
status_by_id
.get(id.as_str())
.copied()
.is_none_or(|s| s != TodoStatus::Completed)
});
if let Some(first) = blockers.next() {
let _ = write!(out, " (blocked by: {first}");
for blocker in blockers {
let _ = write!(out, ", {blocker}");
}
out.push(')');
}
out.push('\n');
}
Ok(out.trim_end().to_string())
})
}),
source: ToolSource::Native,
}
}
#[cfg(test)]
mod kimi_todo_tests {
use std::sync::Arc;
use serde_json::json;
use tokio_util::sync::CancellationToken;
use super::tests::SilentEmitter;
use super::*;
use crate::sandbox::Sandbox;
use crate::test_support::MockSandbox;
fn ctx() -> ToolContext {
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env_provider: None,
session_id: Some("ses_kimi".to_string()),
root_session_id: Some("ses_kimi".to_string()),
tool_call_id: None,
agent_event_emitter: Some(Arc::new(SilentEmitter)),
}
}
async fn call(tool: &RegisteredTool, args: serde_json::Value) -> Result<String, String> {
(tool.executor)(args, ctx()).await
}
#[tokio::test]
async fn replaces_the_whole_list_and_reads_it_back() {
let runtime = Arc::new(TodoRuntime::new());
let tool = make_todo_list_tool(runtime);
call(
&tool,
json!({"todos": [
{"title": "read the config", "status": "done"},
{"title": "patch the parser", "status": "in_progress"},
{"title": "add a test", "status": "pending"}
]}),
)
.await
.unwrap();
// Read mode: `todos` omitted entirely.
let listed = call(&tool, json!({})).await.unwrap();
assert!(listed.contains("[done] read the config"), "{listed}");
assert!(
listed.contains("[in_progress] patch the parser"),
"{listed}"
);
// Re-submitting a shorter list drops the missing entries.
call(
&tool,
json!({"todos": [{"title": "add a test", "status": "done"}]}),
)
.await
.unwrap();
let listed = call(&tool, json!({})).await.unwrap();
assert!(listed.contains("[done] add a test"), "{listed}");
assert!(!listed.contains("patch the parser"), "{listed}");
}
#[tokio::test]
async fn empty_array_clears_the_list() {
let runtime = Arc::new(TodoRuntime::new());
let tool = make_todo_list_tool(runtime);
call(
&tool,
json!({"todos": [{"title": "x", "status": "pending"}]}),
)
.await
.unwrap();
call(&tool, json!({"todos": []})).await.unwrap();
assert_eq!(
call(&tool, json!({})).await.unwrap(),
"The todo list is empty."
);
}
/// Kimi Code spells the terminal status `done`; `completed` is the
/// Anthropic/Codex spelling and must not be silently accepted.
#[tokio::test]
async fn status_vocabulary_is_kimi_codes() {
let runtime = Arc::new(TodoRuntime::new());
let tool = make_todo_list_tool(runtime);
let err = call(
&tool,
json!({"todos": [{"title": "x", "status": "completed"}]}),
)
.await
.unwrap_err();
assert!(err.contains("expected pending|in_progress|done"), "{err}");
}
#[tokio::test]
async fn duplicate_titles_are_rejected() {
let runtime = Arc::new(TodoRuntime::new());
let tool = make_todo_list_tool(runtime);
let err = call(
&tool,
json!({"todos": [
{"title": "same", "status": "pending"},
{"title": "same", "status": "done"}
]}),
)
.await
.unwrap_err();
assert!(err.contains("must be unique"), "{err}");
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use super::*;
use crate::sandbox::Sandbox;
use crate::test_support::MockSandbox;
use crate::tool_registry::{AgentEventEmitter, ToolContext};
use crate::types::AgentEvent;
#[derive(Default)]
pub(super) struct SilentEmitter;
impl AgentEventEmitter for SilentEmitter {
fn emit(&self, _event: AgentEvent) {}
}
fn ctx_for(session: &str, root: &str) -> ToolContext {
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env_provider: None,
session_id: Some(session.to_string()),
root_session_id: Some(root.to_string()),
tool_call_id: None,
agent_event_emitter: Some(Arc::new(SilentEmitter)),
}
}
fn openai_list(session: &str) -> String {
TodoListKind::OpenAiPlan.list_id(session)
}
fn anthropic_list(session: &str) -> String {
TodoListKind::AnthropicTasks.list_id(session)
}
#[tokio::test]
async fn update_plan_creates_initial_steps() {
let runtime = Arc::new(TodoRuntime::new());
let tool = make_update_plan_tool(runtime.clone());
let ctx = ctx_for("ses_a", "ses_a");
let out = (tool.executor)(
serde_json::json!({
"plan": [
{"step": "a", "status": "pending"},
{"step": "b", "status": "in_progress"},
]
}),
ctx,
)
.await
.unwrap();
assert_eq!(out, "Plan updated");
let list = runtime.snapshot(&openai_list("ses_a")).unwrap();
assert_eq!(list.items.len(), 2);
assert_eq!(list.items[0].subject, "a");
assert_eq!(list.items[1].subject, "b");
assert_eq!(list.items[1].status, TodoStatus::InProgress);
}
#[tokio::test]
async fn update_plan_updates_status_and_order() {
let runtime = Arc::new(TodoRuntime::new());
let tool = make_update_plan_tool(runtime.clone());
(tool.executor)(
serde_json::json!({
"plan": [
{"step": "a", "status": "pending"},
{"step": "b", "status": "pending"},
]
}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
(tool.executor)(
serde_json::json!({
"plan": [
{"step": "b", "status": "in_progress"},
{"step": "a", "status": "completed"},
]
}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
let list = runtime.snapshot(&openai_list("ses_a")).unwrap();
assert_eq!(list.items.len(), 2);
assert_eq!(list.items[0].subject, "b");
assert_eq!(list.items[0].status, TodoStatus::InProgress);
assert_eq!(list.items[1].subject, "a");
assert_eq!(list.items[1].status, TodoStatus::Completed);
}
#[tokio::test]
async fn update_plan_deletes_omitted_steps() {
let runtime = Arc::new(TodoRuntime::new());
let tool = make_update_plan_tool(runtime.clone());
(tool.executor)(
serde_json::json!({
"plan": [
{"step": "a", "status": "pending"},
{"step": "b", "status": "pending"},
{"step": "c", "status": "pending"},
]
}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
(tool.executor)(
serde_json::json!({
"plan": [{"step": "b", "status": "completed"}]
}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
let list = runtime.snapshot(&openai_list("ses_a")).unwrap();
assert_eq!(list.items.len(), 1);
assert_eq!(list.items[0].subject, "b");
}
#[tokio::test]
async fn update_plan_rejects_duplicate_steps() {
let runtime = Arc::new(TodoRuntime::new());
let tool = make_update_plan_tool(runtime);
let err = (tool.executor)(
serde_json::json!({
"plan": [
{"step": "same", "status": "pending"},
{"step": "same", "status": "completed"},
]
}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap_err();
assert!(err.contains("Duplicate plan step"), "got: {err}");
}
#[tokio::test]
async fn update_plan_subagent_writes_different_list_than_parent() {
let runtime = Arc::new(TodoRuntime::new());
let tool = make_update_plan_tool(runtime.clone());
(tool.executor)(
serde_json::json!({"plan": [{"step": "parent_step", "status": "pending"}]}),
ctx_for("ses_parent", "ses_parent"),
)
.await
.unwrap();
(tool.executor)(
serde_json::json!({"plan": [{"step": "child_step", "status": "pending"}]}),
// Subagent session: own session_id is distinct from root.
ctx_for("ses_child", "ses_parent"),
)
.await
.unwrap();
let parent = runtime.snapshot(&openai_list("ses_parent")).unwrap();
let child = runtime.snapshot(&openai_list("ses_child")).unwrap();
assert_eq!(parent.items.len(), 1);
assert_eq!(parent.items[0].subject, "parent_step");
assert_eq!(child.items.len(), 1);
assert_eq!(child.items[0].subject, "child_step");
}
#[tokio::test]
async fn task_create_returns_numeric_id_and_message() {
let runtime = Arc::new(TodoRuntime::new());
let create = make_task_create_tool(runtime.clone());
let out = (create.executor)(
serde_json::json!({"subject": "Do thing", "description": "details"}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
assert_eq!(out, "Task #1 created successfully: Do thing");
let list = runtime.snapshot(&anthropic_list("ses_a")).unwrap();
assert_eq!(list.items.len(), 1);
assert_eq!(list.items[0].id, "1");
assert_eq!(list.items[0].subject, "Do thing");
assert_eq!(list.items[0].description, "details");
}
#[test]
fn anthropic_task_tool_descriptions_are_concise() {
let runtime = Arc::new(TodoRuntime::new());
let create = make_task_create_tool(runtime.clone());
let update = make_task_update_tool(runtime.clone());
let list = make_task_list_tool(runtime);
assert!(
create
.definition
.description
.contains("Create pending tasks")
);
assert!(create.definition.description.contains("activeForm"));
assert!(update.definition.description.contains("pending"));
assert!(update.definition.description.contains("deleted"));
assert!(
list.definition
.description
.contains("blocking dependencies")
);
let total_description_bytes = create.definition.description.len()
+ update.definition.description.len()
+ list.definition.description.len();
assert!(total_description_bytes < 600);
assert!(!create.definition.description.contains("##"));
assert!(!update.definition.description.contains("```"));
}
#[tokio::test]
async fn task_create_list_update_delete_cycle() {
let runtime = Arc::new(TodoRuntime::new());
let create = make_task_create_tool(runtime.clone());
let update = make_task_update_tool(runtime.clone());
let list_tool = make_task_list_tool(runtime.clone());
(create.executor)(
serde_json::json!({"subject": "First", "description": "desc"}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
(create.executor)(
serde_json::json!({"subject": "Second", "description": "desc"}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
let listing = (list_tool.executor)(serde_json::json!({}), ctx_for("ses_a", "ses_a"))
.await
.unwrap();
assert!(listing.contains("#1 [pending] First"));
assert!(listing.contains("#2 [pending] Second"));
(update.executor)(
serde_json::json!({"taskId": "1", "status": "completed"}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
(update.executor)(
serde_json::json!({"taskId": "2", "status": "deleted"}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
let listing = (list_tool.executor)(serde_json::json!({}), ctx_for("ses_a", "ses_a"))
.await
.unwrap();
assert!(listing.contains("#1 [completed] First"));
assert!(!listing.contains("#2"));
}
#[tokio::test]
async fn task_update_metadata_merges_and_null_deletes() {
let runtime = Arc::new(TodoRuntime::new());
let create = make_task_create_tool(runtime.clone());
let update = make_task_update_tool(runtime.clone());
(create.executor)(
serde_json::json!({"subject": "t", "description": "d", "metadata": {"k1": "v1"}}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
(update.executor)(
serde_json::json!({"taskId": "1", "metadata": {"k2": "v2"}}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
(update.executor)(
serde_json::json!({"taskId": "1", "metadata": {"k1": null}}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
let list = runtime.snapshot(&anthropic_list("ses_a")).unwrap();
let meta = &list.items[0].metadata;
assert!(!meta.contains_key("k1"));
assert_eq!(meta.get("k2"), Some(&serde_json::json!("v2")));
}
#[tokio::test]
async fn task_update_omitted_optional_strings_do_not_clear_existing_values() {
let runtime = Arc::new(TodoRuntime::new());
let create = make_task_create_tool(runtime.clone());
let update = make_task_update_tool(runtime.clone());
(create.executor)(
serde_json::json!({
"subject": "t",
"description": "d",
"activeForm": "doing t"
}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
(update.executor)(
serde_json::json!({"taskId": "1", "owner": "alice"}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
(update.executor)(
serde_json::json!({"taskId": "1", "metadata": {"k": "v"}}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
let list = runtime.snapshot(&anthropic_list("ses_a")).unwrap();
assert_eq!(list.items[0].active_form.as_deref(), Some("doing t"));
assert_eq!(list.items[0].owner.as_deref(), Some("alice"));
}
#[tokio::test]
async fn task_update_add_blocks_and_add_blocked_by_dedupe() {
let runtime = Arc::new(TodoRuntime::new());
let create = make_task_create_tool(runtime.clone());
let update = make_task_update_tool(runtime.clone());
(create.executor)(
serde_json::json!({"subject": "t", "description": "d"}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
(update.executor)(
serde_json::json!({
"taskId": "1",
"addBlocks": ["b1", "b2"],
"addBlockedBy": ["c1"]
}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
(update.executor)(
serde_json::json!({
"taskId": "1",
"addBlocks": ["b1", "b3"]
}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
let list = runtime.snapshot(&anthropic_list("ses_a")).unwrap();
assert_eq!(list.items[0].blocks, vec!["b1", "b2", "b3"]);
assert_eq!(list.items[0].blocked_by, vec!["c1"]);
}
#[tokio::test]
async fn task_list_empty_returns_no_tasks_found() {
let runtime = Arc::new(TodoRuntime::new());
let tool = make_task_list_tool(runtime);
let out = (tool.executor)(serde_json::json!({}), ctx_for("ses_a", "ses_a"))
.await
.unwrap();
assert_eq!(out, "No tasks found");
}
#[tokio::test]
async fn task_update_missing_task_returns_not_found() {
let runtime = Arc::new(TodoRuntime::new());
let tool = make_task_update_tool(runtime);
let out = (tool.executor)(
serde_json::json!({"taskId": "999", "status": "completed"}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
assert_eq!(out, "Task not found");
}
#[tokio::test]
async fn task_get_returns_full_task_details() {
let runtime = Arc::new(TodoRuntime::new());
let create = make_task_create_tool(runtime.clone());
let update = make_task_update_tool(runtime.clone());
let get = make_task_get_tool(runtime);
(create.executor)(
serde_json::json!({
"subject": "Investigate failing tests",
"description": "Find the failing assertions and identify the smallest fix."
}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
(update.executor)(
serde_json::json!({
"taskId": "1",
"status": "in_progress",
"owner": "agent-1",
"addBlockedBy": ["2", "3"],
"addBlocks": ["4"]
}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
let out = (get.executor)(
serde_json::json!({"taskId": "1"}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
assert_eq!(
out,
"\
Task #1: Investigate failing tests
Status: in_progress
Description: Find the failing assertions and identify the smallest fix.
Owner: agent-1
Blocked by: #2, #3
Blocks: #4"
);
}
#[tokio::test]
async fn task_get_missing_task_returns_not_found() {
let runtime = Arc::new(TodoRuntime::new());
let tool = make_task_get_tool(runtime);
let out = (tool.executor)(
serde_json::json!({"taskId": "999"}),
ctx_for("ses_a", "ses_a"),
)
.await
.unwrap();
assert_eq!(out, "Task not found");
}
#[tokio::test]
async fn parent_and_subagent_share_anthropic_task_list() {
let runtime = Arc::new(TodoRuntime::new());
let create = make_task_create_tool(runtime.clone());
// Parent: session_id == root_session_id.
(create.executor)(
serde_json::json!({"subject": "p", "description": "d"}),
ctx_for("ses_parent", "ses_parent"),
)
.await
.unwrap();
// Subagent: own session id but inherits parent's root.
(create.executor)(
serde_json::json!({"subject": "c", "description": "d"}),
ctx_for("ses_child", "ses_parent"),
)
.await
.unwrap();
// Only one list keyed by the parent root.
assert!(runtime.snapshot(&anthropic_list("ses_child")).is_none());
let list = runtime.snapshot(&anthropic_list("ses_parent")).unwrap();
assert_eq!(list.items.len(), 2);
}
}