use super::{CarriedState, Tool, ToolCtx, ToolOutput};
use crate::compact::CARRIED_HEADER;
use crate::goal::GoalRef;
const SERVES: &str = "serves";
use crate::message::{Block, Message};
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Pending,
InProgress,
Completed,
}
impl Status {
fn marker(self) -> &'static str {
match self {
Status::Pending => "[ ]",
Status::InProgress => "[~]",
Status::Completed => "[x]",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TodoItem {
pub content: String,
pub status: Status,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Plan {
pub goal: Option<GoalRef>,
pub items: Vec<TodoItem>,
}
#[derive(Default)]
pub struct TodoTool {
lists: Mutex<HashMap<PathBuf, Tracked>>,
}
#[derive(Default)]
struct Tracked {
plan: Plan,
started: HashMap<String, Mark>,
flagged: std::collections::HashSet<String>,
own_calls: u32,
last_real: Option<crate::step::Outcome>,
next_own_position: Option<u32>,
completed: Vec<(String, u32)>,
}
const COMPLETED_HISTORY_CAP: usize = 20;
#[derive(Clone, Copy)]
struct Mark {
work: crate::step::Work,
own_calls: u32,
}
impl Tracked {
fn observe(&mut self, work: Option<crate::step::Work>) -> u32 {
let before = self.own_calls;
if let Some(work) = work {
let settled = work.calls.saturating_sub(work.denied);
if self.next_own_position != Some(settled) {
self.last_real = work.last;
}
self.next_own_position = Some(work.calls + work.in_flight + 1);
}
self.own_calls += 1;
before
}
fn advance(
&mut self,
next: Plan,
work: Option<crate::step::Work>,
own_calls_before: u32,
last_real: Option<crate::step::Outcome>,
step_escalation: Option<
&std::sync::Arc<std::sync::Mutex<Option<crate::step::StepEscalation>>>,
>,
) -> Vec<String> {
let before: HashMap<&str, Status> = self
.plan
.items
.iter()
.map(|i| (i.content.as_str(), i.status))
.collect();
let live: std::collections::HashSet<&str> =
next.items.iter().map(|i| i.content.as_str()).collect();
let completed_before_this_batch: Vec<(String, u32)> = self
.completed
.iter()
.filter(|(k, _)| live.contains(k.as_str()))
.cloned()
.collect();
let mut lines = Vec::new();
for item in &next.items {
let was = before.get(item.content.as_str()).copied();
match item.status {
Status::InProgress if was != Some(Status::InProgress) => {
if let Some(work) = work {
self.started.insert(
item.content.clone(),
Mark {
work,
own_calls: own_calls_before,
},
);
}
}
Status::Completed if was != Some(Status::Completed) => {
let Some(mark) = self.started.remove(&item.content) else {
continue;
};
let Some(span) = work.and_then(|w| {
w.since(
mark.work,
own_calls_before.saturating_sub(mark.own_calls),
last_real,
)
}) else {
continue;
};
let finding = crate::step::appraise(span);
match finding.line(&item.content, self.flagged.contains(&item.content)) {
Some(line) => {
self.flagged.insert(item.content.clone());
lines.push(line);
}
None if span.in_flight == 0 && span.denied == 0 => {
self.flagged.remove(&item.content);
if let Some(slot) = step_escalation {
let siblings_excluding_self: Vec<(String, u32)> =
completed_before_this_batch
.iter()
.filter(|(k, _)| k != &item.content)
.cloned()
.collect();
if let Some(escalation) = crate::step::escalation_candidate(
span,
&item.content,
&siblings_excluding_self,
) {
let mut guard = slot.lock().unwrap();
if guard.is_none() {
*guard = Some(escalation);
}
}
}
self.completed.retain(|(k, _)| k != &item.content);
self.completed.push((item.content.clone(), span.calls));
if self.completed.len() > COMPLETED_HISTORY_CAP {
self.completed.remove(0);
}
}
None => {}
}
}
_ => {}
}
}
self.started.retain(|k, _| live.contains(k.as_str()));
self.flagged.retain(|k| live.contains(k.as_str()));
self.completed.retain(|(k, _)| live.contains(k.as_str()));
drop(live);
self.plan = next;
lines
}
}
impl TodoTool {
pub fn new() -> Self {
Self::default()
}
pub fn set_plan_in(&self, workspace: &Path, plan: Plan) {
self.lists.lock().unwrap().insert(
workspace.into(),
Tracked {
plan,
..Tracked::default()
},
);
}
pub fn rehydrate(&self, workspace: &Path, messages: &[Message]) -> Option<usize> {
let plan = Self::plan_from_transcript(messages)?;
let n = plan.items.len();
self.set_plan_in(workspace, plan);
Some(n)
}
pub fn from_transcript(messages: &[Message]) -> Option<Vec<TodoItem>> {
Self::plan_from_transcript(messages).map(|p| p.items)
}
pub fn plan_from_transcript(messages: &[Message]) -> Option<Plan> {
let failed: std::collections::HashSet<&str> = messages
.iter()
.flat_map(|m| m.content.iter())
.filter_map(|b| match b {
Block::ToolResult {
tool_use_id,
is_error: true,
..
} => Some(tool_use_id.as_str()),
_ => None,
})
.collect();
for msg in messages.iter().rev() {
for block in msg.content.iter().rev() {
match block {
Block::ToolUse { id, name, input }
if name == "todo" && !failed.contains(id.as_str()) =>
{
if let Some(items) = input.get("items") {
if let Ok(items) =
serde_json::from_value::<Vec<TodoItem>>(items.clone())
{
let goal = input
.get("serves")
.and_then(Value::as_str)
.and_then(GoalRef::parse_lenient);
return Some(Plan { goal, items });
}
}
}
Block::Text { text } if text.trim_start().starts_with(CARRIED_HEADER) => {
let plan = Self::parse_carried(text);
if !plan.items.is_empty() {
return Some(plan);
}
}
_ => {}
}
}
}
None
}
fn parse_carried(text: &str) -> Plan {
let mut lines = text.lines().skip_while(|l| l.trim() != "## todo");
if lines.next().is_none() {
return Plan::default();
}
let section: Vec<&str> = lines
.take_while(|l| !l.trim_start().starts_with("## "))
.collect();
let goal = section
.iter()
.find(|l| !l.trim().is_empty())
.and_then(|l| l.trim().strip_prefix(SERVES))
.and_then(GoalRef::parse_lenient);
let items = section
.iter()
.filter_map(|line| {
let line = line.trim();
let (marker, rest) = line.split_at(line.char_indices().nth(3)?.0);
let status = match marker {
"[ ]" => Status::Pending,
"[~]" => Status::InProgress,
"[x]" => Status::Completed,
_ => return None,
};
let content = rest.trim();
(!content.is_empty()).then(|| TodoItem {
content: content.to_string(),
status,
})
})
.collect();
Plan { goal, items }
}
pub fn items_in(&self, workspace: &Path) -> Vec<TodoItem> {
self.lists
.lock()
.unwrap()
.get(workspace)
.map(|t| t.plan.items.clone())
.unwrap_or_default()
}
pub fn goal_in(&self, workspace: &Path) -> Option<GoalRef> {
self.lists.lock().unwrap().get(workspace)?.plan.goal.clone()
}
fn render(plan: &Plan) -> String {
if plan.items.is_empty() {
return match &plan.goal {
Some(goal) => format!("{SERVES} {goal}\n(the list is empty)"),
None => "(the list is empty)".to_string(),
};
}
let done = plan
.items
.iter()
.filter(|i| i.status == Status::Completed)
.count();
let mut out = String::new();
if let Some(goal) = &plan.goal {
out.push_str(&format!("{SERVES} {goal}\n"));
}
out.push_str(&format!("{done}/{} done\n", plan.items.len()));
for item in &plan.items {
out.push_str(&format!("{} {}\n", item.status.marker(), item.content));
}
out
}
}
#[async_trait]
impl Tool for TodoTool {
fn name(&self) -> &str {
"todo"
}
fn description(&self) -> &str {
"Record and update your task list for multi-step work. If a task will take more \
than three tool calls, call this FIRST, before any other tool, and keep the list \
updated as you work. Pass the COMPLETE list every time — it replaces what was \
there, so include finished items with status `completed`. Exactly one item should \
be `in_progress` at a time, and an item should be marked `completed` as soon as \
it is done rather than in a batch at the end. If the work serves a task on \
the board, pass `serves` — and pass it on every write, like `items`, \
because both replace what was there. Skip this tool only for work of \
one or two steps."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"items": {
"type": "array",
"description": "The complete task list, in order.",
"items": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "One concrete step, phrased as an action."
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed"]
}
},
"required": ["content", "status"]
}
},
"serves": {
"type": "string",
"description": "Optional. What this whole plan is working toward, as \
`task:<id>` for a task on the board. Pass it on every \
write, like `items` — it is replaced, not merged."
}
},
"required": ["items"]
})
}
fn read_only(&self) -> bool {
true
}
fn carried_state(&self, ctx: &ToolCtx) -> Option<CarriedState> {
let lists = self.lists.lock().unwrap();
let plan = &lists.get(&ctx.workspace)?.plan;
if plan.items.is_empty() {
return None;
}
Some(CarriedState {
label: "todo".into(),
body: Self::render(plan),
})
}
fn forget_conversation_state(&self) {
self.lists.lock().unwrap().clear();
}
async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let (own_calls_before, last_real) = {
let mut lists = self.lists.lock().unwrap();
let tracked = lists.entry(ctx.workspace.clone()).or_default();
let own_calls_before = tracked.observe(ctx.work);
(own_calls_before, tracked.last_real)
};
let Some(raw) = input.get("items").and_then(Value::as_array) else {
return Ok(ToolOutput::err(
"`items` must be an array of {content, status}",
));
};
let mut items = Vec::with_capacity(raw.len());
for (i, entry) in raw.iter().enumerate() {
let Some(content) = entry.get("content").and_then(Value::as_str) else {
return Ok(ToolOutput::err(format!("item {i} has no `content` string")));
};
let status = match entry.get("status").and_then(Value::as_str) {
Some("pending") => Status::Pending,
Some("in_progress") => Status::InProgress,
Some("completed") => Status::Completed,
other => {
return Ok(ToolOutput::err(format!(
"item {i} has status {other:?}; expected pending, in_progress, or completed"
)))
}
};
items.push(TodoItem {
content: content.to_string(),
status,
});
}
let in_progress = items
.iter()
.filter(|i| i.status == Status::InProgress)
.count();
let mut note = String::new();
if in_progress > 1 {
note = format!(
"\n(note: {in_progress} items are in_progress — finish one before starting another)"
);
}
let goal = match input.get("serves") {
None | Some(Value::Null) => None,
Some(value) => {
let Some(raw) = value.as_str() else {
return Ok(ToolOutput::err(
"`serves` must be a string like `task:<id>`",
));
};
if raw.trim().is_empty() {
None
} else {
match raw.parse::<GoalRef>() {
Ok(goal) => Some(goal),
Err(e) => return Ok(ToolOutput::err(format!("`serves`: {e}"))),
}
}
}
};
let plan = Plan { goal, items };
let rendered = Self::render(&plan);
let findings = self
.lists
.lock()
.unwrap()
.entry(ctx.workspace.clone())
.or_default()
.advance(
plan,
ctx.work,
own_calls_before,
last_real,
ctx.step_escalation.as_ref(),
);
let findings = match findings.is_empty() {
true => String::new(),
false => format!("\n\n{}", findings.join("\n")),
};
let context = match &ctx.context {
Some(f) => format!("\n\n{f}"),
None => String::new(),
};
Ok(ToolOutput::ok(format!(
"{rendered}{note}{findings}{context}"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn writing_the_list_echoes_it_back_with_progress() {
let tool = TodoTool::new();
let ctx = ToolCtx::default();
let out = tool
.call(
json!({"items": [
{"content": "read the config", "status": "completed"},
{"content": "fix the port", "status": "in_progress"},
{"content": "run the tests", "status": "pending"}
]}),
&ctx,
)
.await
.unwrap();
assert!(!out.is_error);
assert!(out.content.starts_with("1/3 done"));
assert!(out.content.contains("[x] read the config"));
assert!(out.content.contains("[~] fix the port"));
assert!(out.content.contains("[ ] run the tests"));
assert_eq!(tool.items_in(&ctx.workspace).len(), 3);
}
#[tokio::test]
async fn the_list_is_replaced_not_appended() {
let tool = TodoTool::new();
let ctx = ToolCtx::default();
tool.call(
json!({"items": [{"content": "a", "status": "pending"}]}),
&ctx,
)
.await
.unwrap();
tool.call(
json!({"items": [{"content": "b", "status": "pending"}]}),
&ctx,
)
.await
.unwrap();
let items = tool.items_in(&ctx.workspace);
assert_eq!(items.len(), 1, "a write replaces the whole list");
assert_eq!(items[0].content, "b");
}
#[tokio::test]
async fn a_plan_can_name_what_it_serves_and_echoes_it_above_the_list() {
let tool = TodoTool::new();
let ctx = ToolCtx::default();
let out = tool
.call(
json!({
"items": [{"content": "draft the reply", "status": "in_progress"}],
"serves": "task:01J8ZK",
}),
&ctx,
)
.await
.unwrap();
assert!(!out.is_error);
assert!(
out.content.starts_with("serves task:01J8ZK\n"),
"{}",
out.content
);
assert_eq!(
tool.goal_in(&ctx.workspace),
Some(GoalRef::Task("01J8ZK".into()))
);
}
#[tokio::test]
async fn a_malformed_goal_is_reported_rather_than_silently_dropped() {
let tool = TodoTool::new();
let ctx = ToolCtx::default();
let out = tool
.call(
json!({
"items": [{"content": "a", "status": "pending"}],
"serves": "epic:7",
}),
&ctx,
)
.await
.unwrap();
assert!(out.is_error);
assert!(
out.content.contains("not a kind of goal"),
"{}",
out.content
);
assert!(
tool.items_in(&ctx.workspace).is_empty(),
"a rejected write changes nothing"
);
}
#[tokio::test]
async fn a_plan_that_serves_nothing_renders_no_goal_line() {
let tool = TodoTool::new();
let ctx = ToolCtx::default();
let out = tool
.call(
json!({"items": [{"content": "a", "status": "pending"}]}),
&ctx,
)
.await
.unwrap();
assert!(!out.is_error);
assert!(out.content.starts_with("0/1 done"), "{}", out.content);
assert_eq!(tool.goal_in(&ctx.workspace), None);
}
#[tokio::test]
async fn a_non_string_goal_is_reported_rather_than_silently_dropped() {
let tool = TodoTool::new();
let ctx = ToolCtx::default();
let out = tool
.call(
json!({
"items": [{"content": "a", "status": "pending"}],
"serves": {"kind": "task", "id": "01J8ZK"},
}),
&ctx,
)
.await
.unwrap();
assert!(out.is_error, "{}", out.content);
assert!(out.content.contains("must be a string"), "{}", out.content);
assert!(tool.items_in(&ctx.workspace).is_empty());
}
#[tokio::test]
async fn an_empty_goal_means_omitted_and_does_not_cost_the_write() {
let tool = TodoTool::new();
let ctx = ToolCtx::default();
let out = tool
.call(
json!({
"items": [{"content": "a", "status": "pending"}],
"serves": "",
}),
&ctx,
)
.await
.unwrap();
assert!(!out.is_error, "{}", out.content);
assert_eq!(tool.items_in(&ctx.workspace).len(), 1, "the plan was kept");
assert_eq!(tool.goal_in(&ctx.workspace), None);
}
#[tokio::test]
async fn an_empty_list_still_says_what_it_was_for() {
let tool = TodoTool::new();
let ctx = ToolCtx::default();
let out = tool
.call(json!({"items": [], "serves": "task:01J8ZK"}), &ctx)
.await
.unwrap();
assert!(!out.is_error);
assert!(
out.content.contains("serves task:01J8ZK"),
"{}",
out.content
);
}
#[tokio::test]
async fn a_bad_status_is_reported_rather_than_silently_dropped() {
let tool = TodoTool::new();
let ctx = ToolCtx::default();
let out = tool
.call(json!({"items": [{"content": "a", "status": "done"}]}), &ctx)
.await
.unwrap();
assert!(out.is_error);
assert!(out.content.contains("expected pending"));
assert!(
tool.items_in(&ctx.workspace).is_empty(),
"a rejected write changes nothing"
);
}
fn ctx_in(dir: &str) -> ToolCtx {
ToolCtx {
workspace: PathBuf::from(dir),
..Default::default()
}
}
#[tokio::test]
async fn two_workspaces_keep_separate_lists() {
let tool = TodoTool::new();
let (a, b) = (ctx_in("/w/a"), ctx_in("/w/b"));
tool.call(
json!({"items": [{"content": "a", "status": "pending"}]}),
&a,
)
.await
.unwrap();
tool.call(
json!({"items": [{"content": "b", "status": "pending"}]}),
&b,
)
.await
.unwrap();
let (ia, ib) = (tool.items_in(&a.workspace), tool.items_in(&b.workspace));
assert_eq!(ia.len(), 1);
assert_eq!(ib.len(), 1);
assert_eq!(ia[0].content, "a", "b's write must not reach a's list");
assert_eq!(ib[0].content, "b");
}
use crate::message::Role;
fn todo_call(id: &str, items: &[(&str, &str)]) -> Message {
let items: Vec<Value> = items
.iter()
.map(|(c, s)| json!({"content": c, "status": s}))
.collect();
Message {
role: Role::Assistant,
content: vec![Block::ToolUse {
id: id.into(),
name: "todo".into(),
input: json!({ "items": items }),
}],
}
}
fn result(id: &str, is_error: bool) -> Message {
Message {
role: Role::User,
content: vec![Block::ToolResult {
tool_use_id: id.into(),
content: "ok".into(),
is_error,
}],
}
}
#[tokio::test]
async fn a_resumed_transcript_restores_the_last_plan() {
let tool = TodoTool::new();
let ws = PathBuf::from("/w/a");
let msgs = vec![
todo_call("t1", &[("first", "completed")]),
result("t1", false),
todo_call("t2", &[("first", "completed"), ("second", "in_progress")]),
result("t2", false),
];
assert!(tool.items_in(&ws).is_empty(), "nothing before the resume");
assert_eq!(tool.rehydrate(&ws, &msgs), Some(2));
let items = tool.items_in(&ws);
assert_eq!(items[0].content, "first");
assert_eq!(items[1].status, Status::InProgress);
}
#[tokio::test]
async fn a_rejected_write_is_not_restored() {
let tool = TodoTool::new();
let ws = PathBuf::from("/w/a");
let msgs = vec![
todo_call("t1", &[("real plan", "in_progress")]),
result("t1", false),
todo_call("t2", &[("rejected plan", "in_progress")]),
result("t2", true),
];
tool.rehydrate(&ws, &msgs).unwrap();
let items = tool.items_in(&ws);
assert_eq!(items.len(), 1);
assert_eq!(
items[0].content, "real plan",
"the rejected write is skipped"
);
}
#[tokio::test]
async fn a_compacted_transcript_restores_from_the_carried_block() {
let tool = TodoTool::new();
let ws = PathBuf::from("/w/a");
let head = Message {
role: Role::User,
content: vec![
Block::text("the original task"),
Block::text("\n\n[Earlier turns were compacted to fit the context window.]"),
Block::text(format!(
"\n\n{CARRIED_HEADER}\n\n## todo\n1/2 done\n [x] read the thread\n[~] draft the reply\n"
)),
],
};
assert_eq!(tool.rehydrate(&ws, &[head]), Some(2));
let items = tool.items_in(&ws);
assert_eq!(items[0].content, "read the thread");
assert_eq!(items[0].status, Status::Completed);
assert_eq!(items[1].content, "draft the reply");
assert_eq!(items[1].status, Status::InProgress);
}
#[tokio::test]
async fn a_write_after_the_compaction_beats_the_carried_block() {
let tool = TodoTool::new();
let ws = PathBuf::from("/w/a");
let msgs = vec![
Message {
role: Role::User,
content: vec![Block::text(format!(
"{CARRIED_HEADER}\n\n## todo\n0/1 done\n[ ] stale\n"
))],
},
todo_call("t9", &[("current", "in_progress")]),
result("t9", false),
];
tool.rehydrate(&ws, &msgs).unwrap();
assert_eq!(tool.items_in(&ws)[0].content, "current");
}
#[test]
fn rendering_and_parsing_round_trip() {
let items = vec![
TodoItem {
content: "read the config".into(),
status: Status::Completed,
},
TodoItem {
content: "fix the port".into(),
status: Status::InProgress,
},
TodoItem {
content: "run the tests".into(),
status: Status::Pending,
},
];
let plan = Plan {
goal: Some(GoalRef::Task("01J8ZK".into())),
items: items.clone(),
};
let block = format!("{CARRIED_HEADER}\n\n## todo\n{}\n", TodoTool::render(&plan));
assert!(
block.contains("serves task:01J8ZK"),
"the goal is rendered above the list: {block}"
);
let back = TodoTool::parse_carried(&block);
assert_eq!(back, plan);
let bare = Plan { goal: None, items };
let block = format!("{CARRIED_HEADER}\n\n## todo\n{}\n", TodoTool::render(&bare));
assert_eq!(TodoTool::parse_carried(&block), bare);
}
#[test]
fn an_item_whose_content_looks_like_a_goal_line_does_not_become_one() {
let block =
format!("{CARRIED_HEADER}\n\n## todo\n0/1 done\n[ ] paste this:\nserves task:99\n");
assert_eq!(TodoTool::parse_carried(&block).goal, None);
}
#[test]
fn a_carried_goal_of_an_unknown_kind_does_not_cost_the_plan() {
let block = format!("{CARRIED_HEADER}\n\n## todo\nserves epic:7\n1/1 done\n[x] mine\n");
let back = TodoTool::parse_carried(&block);
assert_eq!(back.goal, None);
assert_eq!(back.items.len(), 1, "the plan survives its unreadable goal");
}
#[test]
fn a_neighbouring_carried_section_is_not_absorbed() {
let block =
format!("{CARRIED_HEADER}\n\n## todo\n1/1 done\n[x] mine\n\n## skill\n[x] not mine\n");
let plan = TodoTool::parse_carried(&block);
assert_eq!(plan.items.len(), 1);
assert_eq!(plan.items[0].content, "mine");
}
#[test]
fn a_transcript_with_no_plan_restores_nothing() {
assert!(TodoTool::from_transcript(&[Message::user("hello")]).is_none());
assert!(TodoTool::from_transcript(&[]).is_none());
}
#[tokio::test]
async fn clearing_a_conversation_drops_its_plan() {
let tool = TodoTool::new();
let ctx = ToolCtx::default();
tool.call(
json!({"items": [{"content": "old business", "status": "in_progress"}]}),
&ctx,
)
.await
.unwrap();
assert_eq!(tool.items_in(&ctx.workspace).len(), 1);
tool.forget_conversation_state();
assert!(tool.items_in(&ctx.workspace).is_empty(), "the plan is gone");
assert!(
tool.carried_state(&ctx).is_none(),
"and cannot reach the next conversation's compaction"
);
}
#[tokio::test]
async fn carried_state_belongs_to_the_run_being_compacted() {
let tool = TodoTool::new();
let (a, b) = (ctx_in("/w/a"), ctx_in("/w/b"));
tool.call(
json!({"items": [{"content": "ship a", "status": "in_progress"}]}),
&a,
)
.await
.unwrap();
tool.call(
json!({"items": [{"content": "ship b", "status": "in_progress"}]}),
&b,
)
.await
.unwrap();
let carried = tool.carried_state(&a).expect("a has a list to carry");
assert!(carried.body.contains("ship a"));
assert!(
!carried.body.contains("ship b"),
"a compaction must not carry another conversation's plan"
);
assert!(tool.carried_state(&ctx_in("/w/c")).is_none());
}
#[tokio::test]
async fn multiple_in_progress_items_get_a_nudge() {
let tool = TodoTool::new();
let out = tool
.call(
json!({"items": [
{"content": "a", "status": "in_progress"},
{"content": "b", "status": "in_progress"}
]}),
&ToolCtx::default(),
)
.await
.unwrap();
assert!(!out.is_error, "the write still lands");
assert!(out.content.contains("finish one before starting another"));
}
use crate::step::{Outcome, Work};
fn work_ctx(run: u64, calls: u32, last: Option<Outcome>) -> ToolCtx {
ToolCtx {
work: Some(
Work {
calls,
last,
..Work::default()
}
.in_run(run),
),
..ToolCtx::default()
}
}
fn batched_work_ctx(run: u64, calls: u32, last: Option<Outcome>, in_flight: u32) -> ToolCtx {
ToolCtx {
work: Some(
Work {
calls,
last,
in_flight,
..Work::default()
}
.in_run(run),
),
..ToolCtx::default()
}
}
async fn write(tool: &TodoTool, ctx: &ToolCtx, items: Value) -> String {
tool.call(json!({ "items": items }), ctx)
.await
.unwrap()
.content
}
#[tokio::test]
async fn a_step_with_work_behind_it_is_appraised_silently() {
let tool = TodoTool::new();
write(
&tool,
&work_ctx(1, 0, None),
json!([{"content": "fix the port", "status": "in_progress"}]),
)
.await;
let out = write(
&tool,
&work_ctx(1, 3, Some(Outcome::Ok)),
json!([{"content": "fix the port", "status": "completed"}]),
)
.await;
assert!(
!out.contains("fix the port\""),
"the common path says nothing: {out}"
);
}
#[tokio::test]
async fn a_step_marked_done_with_nothing_behind_it_says_so() {
let tool = TodoTool::new();
write(
&tool,
&work_ctx(1, 0, None),
json!([{"content": "fix the port", "status": "in_progress"}]),
)
.await;
let out = write(
&tool,
&work_ctx(1, 1, Some(Outcome::Ok)),
json!([{"content": "fix the port", "status": "completed"}]),
)
.await;
assert!(out.contains("no tool calls behind it"), "{out}");
assert!(out.starts_with("1/1 done"));
}
#[tokio::test]
async fn revising_the_plan_is_not_work() {
let tool = TodoTool::new();
let started = json!([{"content": "fix the port", "status": "in_progress"}]);
write(&tool, &work_ctx(1, 0, None), started.clone()).await;
write(&tool, &work_ctx(1, 1, Some(Outcome::Ok)), started).await;
let out = write(
&tool,
&work_ctx(1, 2, Some(Outcome::Ok)),
json!([{"content": "fix the port", "status": "completed"}]),
)
.await;
assert!(out.contains("no tool calls behind it"), "{out}");
}
#[tokio::test]
async fn a_step_never_seen_in_progress_is_not_appraised() {
let tool = TodoTool::new();
let out = write(
&tool,
&work_ctx(1, 4, Some(Outcome::Ok)),
json!([{"content": "fix the port", "status": "completed"}]),
)
.await;
assert!(!out.contains("no tool calls"), "{out}");
}
#[tokio::test]
async fn an_unstamped_context_makes_no_claim() {
let tool = TodoTool::new();
let bare = ToolCtx::default();
write(
&tool,
&bare,
json!([{"content": "fix the port", "status": "in_progress"}]),
)
.await;
let out = write(
&tool,
&bare,
json!([{"content": "fix the port", "status": "completed"}]),
)
.await;
assert!(
!out.contains("no tool calls"),
"nobody measured, so nothing is claimed: {out}"
);
}
#[tokio::test]
async fn a_step_spanning_two_runs_is_unmeasurable_rather_than_empty() {
let tool = TodoTool::new();
write(
&tool,
&work_ctx(1, 6, Some(Outcome::Ok)),
json!([{"content": "fix the port", "status": "in_progress"}]),
)
.await;
let out = write(
&tool,
&work_ctx(2, 1, Some(Outcome::Ok)),
json!([{"content": "fix the port", "status": "completed"}]),
)
.await;
assert!(!out.contains("no tool calls"), "{out}");
}
#[tokio::test]
async fn a_second_bad_reading_on_one_step_stops_asking_for_a_revision() {
let tool = TodoTool::new();
let started = json!([{"content": "fix the port", "status": "in_progress"}]);
let done = json!([{"content": "fix the port", "status": "completed"}]);
write(&tool, &work_ctx(1, 0, None), started.clone()).await;
let first = write(&tool, &work_ctx(1, 1, Some(Outcome::Ok)), done.clone()).await;
assert!(first.contains("no tool calls behind it") && !first.contains("second time"));
write(&tool, &work_ctx(1, 2, Some(Outcome::Ok)), started).await;
let second = write(&tool, &work_ctx(1, 3, Some(Outcome::Ok)), done).await;
assert!(second.contains("second time"), "{second}");
}
#[tokio::test]
async fn a_refused_step_is_reported_as_blocked_and_not_as_broken() {
let tool = TodoTool::new();
write(
&tool,
&work_ctx(1, 0, None),
json!([{"content": "publish the site", "status": "in_progress"}]),
)
.await;
let out = write(
&tool,
&work_ctx(1, 3, Some(Outcome::Refused)),
json!([{"content": "publish the site", "status": "completed"}]),
)
.await;
assert!(out.contains("refused"), "{out}");
assert!(
!out.contains("still failing"),
"the approver doing its job is not the step going wrong: {out}"
);
}
#[tokio::test]
async fn a_bookkeeping_revision_does_not_mask_an_earlier_failure() {
let tool = TodoTool::new();
let step = json!([{"content": "ship the release", "status": "in_progress"}]);
write(&tool, &work_ctx(1, 0, None), step.clone()).await;
write(&tool, &work_ctx(1, 2, Some(Outcome::Failed)), step).await;
let out = write(
&tool,
&work_ctx(1, 3, Some(Outcome::Ok)),
json!([{"content": "ship the release", "status": "completed"}]),
)
.await;
assert!(
out.contains("still failing"),
"the revision's own `Ok` must not bury the real failure: {out}"
);
}
#[tokio::test]
async fn a_rejected_write_does_not_manufacture_a_step_failure() {
let tool = TodoTool::new();
let step = json!([{"content": "ship the release", "status": "in_progress"}]);
write(&tool, &work_ctx(1, 0, None), step).await;
tool.call(
json!({"items": [{"content": "ship the release", "status": "not_a_status"}]}),
&work_ctx(1, 2, Some(Outcome::Ok)),
)
.await
.unwrap();
let out = write(
&tool,
&work_ctx(1, 3, Some(Outcome::Failed)),
json!([{"content": "ship the release", "status": "completed"}]),
)
.await;
assert!(
!out.contains("still failing"),
"a rejected bookkeeping write is not the step's own failure: {out}"
);
assert!(
!out.contains("no tool calls behind it"),
"the real call succeeded, so the span is not empty either: {out}"
);
}
#[tokio::test]
async fn a_rejected_write_batched_with_a_sibling_does_not_manufacture_a_failure() {
let tool = TodoTool::new();
let step = json!([{"content": "ship the release", "status": "in_progress"}]);
write(&tool, &work_ctx(1, 0, None), step).await;
tool.call(
json!({"items": [{"content": "ship the release", "status": "not_a_status"}]}),
&batched_work_ctx(1, 1, Some(Outcome::Ok), 1),
)
.await
.unwrap();
let out = write(
&tool,
&work_ctx(1, 3, Some(Outcome::Failed)),
json!([{"content": "ship the release", "status": "completed"}]),
)
.await;
assert!(
!out.contains("still failing"),
"a rejected bookkeeping write batched with a sibling is not the \
step's own failure: {out}"
);
}
fn escalation_ctx(
run: u64,
calls: u32,
last: Option<Outcome>,
verify_like: u32,
) -> (
ToolCtx,
std::sync::Arc<std::sync::Mutex<Option<crate::step::StepEscalation>>>,
) {
let slot = std::sync::Arc::new(std::sync::Mutex::new(None));
let ctx = ToolCtx {
work: Some(
Work {
calls,
last,
verify_like,
shell_calls: calls,
..Work::default()
}
.in_run(run),
),
step_escalation: Some(slot.clone()),
..ToolCtx::default()
};
(ctx, slot)
}
#[tokio::test]
async fn a_span_outlier_with_no_escalation_slot_writes_nothing_and_does_not_panic() {
let tool = TodoTool::new();
for i in 0..2 {
let step = format!("small step {i}");
write(
&tool,
&work_ctx(1, i * 3, None),
json!([{"content": step, "status": "in_progress"}]),
)
.await;
write(
&tool,
&work_ctx(1, i * 3 + 2, Some(Outcome::Ok)),
json!([{"content": step, "status": "completed"}]),
)
.await;
}
write(
&tool,
&work_ctx(1, 6, None),
json!([{"content": "a huge step", "status": "in_progress"}]),
)
.await;
write(
&tool,
&work_ctx(1, 30, Some(Outcome::Ok)),
json!([{"content": "a huge step", "status": "completed"}]),
)
.await;
}
#[tokio::test]
async fn a_span_outlier_writes_a_candidate_into_the_slot_when_present() {
let tool = TodoTool::new();
let mut items: Vec<Value> = Vec::new();
for (i, n) in [2u32, 3u32].into_iter().enumerate() {
let step = format!("small step {i}");
items.push(json!({"content": step, "status": "in_progress"}));
let (start_ctx, _) = escalation_ctx(1, 0, None, 0);
write(&tool, &start_ctx, Value::Array(items.clone())).await;
items.last_mut().unwrap()["status"] = json!("completed");
let (done_ctx, _) = escalation_ctx(1, n, Some(Outcome::Ok), 0);
write(&tool, &done_ctx, Value::Array(items.clone())).await;
}
items.push(json!({"content": "a huge step", "status": "in_progress"}));
let (start_ctx, _) = escalation_ctx(1, 5, None, 0);
write(&tool, &start_ctx, Value::Array(items.clone())).await;
items.last_mut().unwrap()["status"] = json!("completed");
let (done_ctx, slot) = escalation_ctx(1, 25, Some(Outcome::Ok), 0);
write(&tool, &done_ctx, Value::Array(items.clone())).await;
let escalation = slot
.lock()
.unwrap()
.clone()
.expect("20 calls against a mean of 2.5 should have written a candidate");
assert_eq!(
escalation.reason,
crate::step::EscalationReason::SpanOutlier
);
assert_eq!(escalation.step, "a huge step");
}
#[tokio::test]
async fn completed_history_is_pruned_once_a_step_leaves_the_live_plan() {
let tool = TodoTool::new();
let mut items: Vec<Value> = Vec::new();
for (i, n) in [2u32, 3u32].into_iter().enumerate() {
let step = format!("small step {i}");
items.push(json!({"content": step, "status": "in_progress"}));
let (start_ctx, _) = escalation_ctx(1, 0, None, 0);
write(&tool, &start_ctx, Value::Array(items.clone())).await;
items.last_mut().unwrap()["status"] = json!("completed");
let (done_ctx, _) = escalation_ctx(1, n, Some(Outcome::Ok), 0);
write(&tool, &done_ctx, Value::Array(items.clone())).await;
}
let (start_ctx, _) = escalation_ctx(1, 5, None, 0);
write(
&tool,
&start_ctx,
json!([{"content": "a huge step", "status": "in_progress"}]),
)
.await;
let (done_ctx, slot) = escalation_ctx(1, 25, Some(Outcome::Ok), 0);
write(
&tool,
&done_ctx,
json!([{"content": "a huge step", "status": "completed"}]),
)
.await;
assert!(
slot.lock().unwrap().is_none(),
"a rewritten plan must not escalate against a mean from steps it no longer holds"
);
}
#[tokio::test]
async fn a_rewrite_and_a_completion_in_the_same_write_still_prunes_the_baseline() {
let tool = TodoTool::new();
let mut items: Vec<Value> = Vec::new();
for (i, n) in [2u32, 3u32].into_iter().enumerate() {
let step = format!("small step {i}");
items.push(json!({"content": step, "status": "in_progress"}));
let (start_ctx, _) = escalation_ctx(1, 0, None, 0);
write(&tool, &start_ctx, Value::Array(items.clone())).await;
items.last_mut().unwrap()["status"] = json!("completed");
let (done_ctx, _) = escalation_ctx(1, n, Some(Outcome::Ok), 0);
write(&tool, &done_ctx, Value::Array(items.clone())).await;
}
items.push(json!({"content": "huge step", "status": "in_progress"}));
let (start_ctx, _) = escalation_ctx(1, 5, None, 0);
write(&tool, &start_ctx, Value::Array(items.clone())).await;
let (done_ctx, slot) = escalation_ctx(1, 40, Some(Outcome::Ok), 0);
write(
&tool,
&done_ctx,
json!([{"content": "huge step", "status": "completed"}]),
)
.await;
assert!(
slot.lock().unwrap().is_none(),
"the same write that drops the small steps from the plan must not let \
huge step's own completion see them as its baseline"
);
}
#[tokio::test]
async fn a_step_revised_and_recompleted_contributes_one_entry_not_two() {
let tool = TodoTool::new();
let mut items: Vec<Value> = Vec::new();
items.push(json!({"content": "small step 0", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 0, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("completed");
write(
&tool,
&escalation_ctx(1, 2, Some(Outcome::Ok), 0).0,
Value::Array(items.clone()),
)
.await;
items.push(json!({"content": "A", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 2, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("completed");
write(
&tool,
&escalation_ctx(1, 5, Some(Outcome::Ok), 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("in_progress");
write(
&tool,
&escalation_ctx(1, 5, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("completed");
write(
&tool,
&escalation_ctx(1, 35, Some(Outcome::Ok), 0).0,
Value::Array(items.clone()),
)
.await;
items.push(json!({"content": "huge step", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 35, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("completed");
let (done_ctx, slot) = escalation_ctx(1, 100, Some(Outcome::Ok), 0);
write(&tool, &done_ctx, Value::Array(items.clone())).await;
let escalation = slot
.lock()
.unwrap()
.clone()
.expect("huge step's span is a clear outlier");
assert_eq!(
escalation.sibling_count, 2,
"A's revision must count once, not twice, among the siblings"
);
assert_eq!(
escalation.siblings.iter().filter(|s| *s == "A").count(),
1,
"A must not be listed as its own sibling twice"
);
}
#[tokio::test]
async fn a_steps_own_pre_revision_entry_does_not_count_as_its_sibling() {
let tool = TodoTool::new();
let mut items: Vec<Value> = Vec::new();
items.push(json!({"content": "small step 0", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 0, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("completed");
write(
&tool,
&escalation_ctx(1, 2, Some(Outcome::Ok), 0).0,
Value::Array(items.clone()),
)
.await;
items.push(json!({"content": "A", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 2, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("completed");
write(
&tool,
&escalation_ctx(1, 4, Some(Outcome::Ok), 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("in_progress");
write(
&tool,
&escalation_ctx(1, 4, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("completed");
let (done_ctx, slot) = escalation_ctx(1, 19, Some(Outcome::Ok), 0);
write(&tool, &done_ctx, Value::Array(items.clone())).await;
assert!(
slot.lock().unwrap().is_none(),
"A's own pre-revision entry must not count as one of its siblings, \
leaving only one real sibling — below SPAN_OUTLIER_MIN_SIBLINGS"
);
}
#[tokio::test]
async fn a_step_landing_earlier_in_the_same_write_does_not_contaminate_a_laters_mean() {
let tool = TodoTool::new();
let mut items: Vec<Value> = Vec::new();
items.push(json!({"content": "small step 0", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 0, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("completed");
write(
&tool,
&escalation_ctx(1, 3, Some(Outcome::Ok), 0).0,
Value::Array(items.clone()),
)
.await;
items.push(json!({"content": "small step 1", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 3, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("completed");
write(
&tool,
&escalation_ctx(1, 7, Some(Outcome::Ok), 0).0,
Value::Array(items.clone()),
)
.await;
items.push(json!({"content": "huge step", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 7, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.push(json!({"content": "medium step", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 37, None, 0).0,
Value::Array(items.clone()),
)
.await;
let last = items.len() - 1;
items[last - 1]["status"] = json!("completed"); items[last]["status"] = json!("completed"); items.swap(last - 1, last); let (done_ctx, slot) = escalation_ctx(1, 42, Some(Outcome::Ok), 0);
write(&tool, &done_ctx, Value::Array(items.clone())).await;
let escalation = slot
.lock()
.unwrap()
.clone()
.expect("huge step's span (33) is a clear outlier against the real baseline");
assert_eq!(escalation.step, "huge step");
assert_eq!(
escalation.sibling_count, 2,
"medium step landed earlier in this same write and must not count as a third sibling"
);
assert_eq!(escalation.sibling_mean_calls, Some(2.5));
}
#[tokio::test]
async fn two_outliers_in_one_write_keep_only_the_first_found() {
let tool = TodoTool::new();
let mut items: Vec<Value> = Vec::new();
items.push(json!({"content": "small step 0", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 0, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("completed");
write(
&tool,
&escalation_ctx(1, 3, Some(Outcome::Ok), 0).0,
Value::Array(items.clone()),
)
.await;
items.push(json!({"content": "small step 1", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 3, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.last_mut().unwrap()["status"] = json!("completed");
write(
&tool,
&escalation_ctx(1, 7, Some(Outcome::Ok), 0).0,
Value::Array(items.clone()),
)
.await;
items.push(json!({"content": "big step A", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 7, None, 0).0,
Value::Array(items.clone()),
)
.await;
items.push(json!({"content": "big step B", "status": "in_progress"}));
write(
&tool,
&escalation_ctx(1, 40, None, 0).0,
Value::Array(items.clone()),
)
.await;
let last = items.len() - 1;
items[last - 1]["status"] = json!("completed"); items[last]["status"] = json!("completed"); let (done_ctx, slot) = escalation_ctx(1, 80, Some(Outcome::Ok), 0);
write(&tool, &done_ctx, Value::Array(items.clone())).await;
let escalation = slot
.lock()
.unwrap()
.clone()
.expect("both steps' spans are clear outliers");
assert_eq!(
escalation.step, "big step A",
"the first candidate `advance` reaches must win, deterministically"
);
}
#[tokio::test]
async fn an_unverified_claim_writes_a_candidate_and_a_verified_one_does_not() {
let tool = TodoTool::new();
let (start_ctx, _) = escalation_ctx(1, 0, None, 0);
write(
&tool,
&start_ctx,
json!([{"content": "test that the API responds", "status": "in_progress"}]),
)
.await;
let (done_ctx, slot) = escalation_ctx(1, 3, Some(Outcome::Ok), 0);
write(
&tool,
&done_ctx,
json!([{"content": "test that the API responds", "status": "completed"}]),
)
.await;
let escalation = slot
.lock()
.unwrap()
.clone()
.expect("an unverified claim should have written a candidate");
assert_eq!(
escalation.reason,
crate::step::EscalationReason::UnverifiedClaim
);
let (start_ctx, _) = escalation_ctx(2, 0, None, 0);
write(
&tool,
&start_ctx,
json!([{"content": "test that the widget renders", "status": "in_progress"}]),
)
.await;
let (done_ctx, slot) = escalation_ctx(2, 3, Some(Outcome::Ok), 1);
write(
&tool,
&done_ctx,
json!([{"content": "test that the widget renders", "status": "completed"}]),
)
.await;
assert!(slot.lock().unwrap().is_none());
}
}