mecha_core/tool/todo.rs
1//! A task list the agent maintains for itself.
2//!
3//! Planning as a *tool* rather than a mode. The alternative — a "plan phase"
4//! that produces a plan and then hands off — goes stale the moment the first
5//! step surprises the model. A list it rewrites as it goes stays honest, and
6//! because the current state is echoed back in every tool result, the model
7//! re-reads its own plan on the next turn without anyone re-prompting it.
8//!
9//! It also gives the *user* something to look at during a long run, which is
10//! most of why it's worth having.
11
12use super::{CarriedState, Tool, ToolCtx, ToolOutput};
13use crate::compact::CARRIED_HEADER;
14use crate::goal::GoalRef;
15
16/// The word introducing the goal line in a rendered plan.
17///
18/// Deliberately the *argument's* name and not better prose. The rendered block
19/// is what the model re-reads after a compaction, and it is the only place the
20/// plan survives; if the line said `serving` while the argument was `serves`,
21/// a post-compaction rewrite would have no way to learn what to call the field
22/// it must pass to keep the goal.
23const SERVES: &str = "serves";
24use crate::message::{Block, Message};
25use anyhow::Result;
26use async_trait::async_trait;
27use serde::{Deserialize, Serialize};
28use serde_json::{json, Value};
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31use std::sync::Mutex;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Status {
36 Pending,
37 InProgress,
38 Completed,
39}
40
41impl Status {
42 fn marker(self) -> &'static str {
43 match self {
44 Status::Pending => "[ ]",
45 Status::InProgress => "[~]",
46 Status::Completed => "[x]",
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct TodoItem {
53 pub content: String,
54 pub status: Status,
55}
56
57/// One conversation's plan: the list, and what the whole of it serves.
58///
59/// **The goal belongs to the plan and not to each item**, which is a claim
60/// about the world rather than a convenience — and it rests on the
61/// conjunction of two facts, one of them very recent.
62///
63/// `TASK-AGENT-DESIGN.md` **D14** keys this tool by the run's workspace: one
64/// workspace, one list. And since `b877e41` (2026-08-26) `tasks work` calls
65/// `work::ensure(task_id)`, so each task run gets a workspace of its own —
66/// before that every task run used the configured workspace and every task on
67/// the board shared one key, which is the bug that commit fixed. Together
68/// those give *one list, one task*, so for a delegated run a per-item goal
69/// models a state that cannot arise, while costing a field the model must
70/// repeat correctly on every item of every write, in the one tool whose whole
71/// job is being cheap to keep updated.
72///
73/// **Not D11.** *One live run per task* is a one-writer rule about two runs
74/// racing; it does not say a run serves only one task, and citing it here
75/// would be the converse of what it states.
76///
77/// **The residual, stated so nobody relies on more than is true.** This tool
78/// is registered once (`setup.rs`) and serves **chat** runs too, keyed by the
79/// same workspace — and a long chat session in one directory legitimately
80/// wanders across goals. There, a reference set early and never revised
81/// becomes a line above the list that misdescribes it. Accepted: the field is
82/// optional, the failure is ordinary staleness the next write corrects, and
83/// per-item references would cost every run to fix the one kind that wanders.
84/// So "cannot arise" is true of task runs and is not universal.
85///
86/// Putting it on the plan also makes the rendering fall out: the goal is one
87/// line *above* the list rather than a suffix that has to be separated from
88/// free-text content on the way back in.
89#[derive(Debug, Clone, Default, PartialEq)]
90pub struct Plan {
91 /// What this plan is a decomposition of. `None` is the ordinary case for
92 /// a chat run nobody delegated.
93 pub goal: Option<GoalRef>,
94 pub items: Vec<TodoItem>,
95}
96
97/// One plan per conversation, keyed by the run's workspace.
98///
99/// It held a single list for the lifetime of the *agent* until 2026-08-26,
100/// which was correct while every front-end holding one served a single
101/// conversation. `mecha serve` is one shared agent across every session, so
102/// two runs shared one list and overwrote each other — and a UI polling the
103/// handle rendered the wrong conversation's plan, which is worse than
104/// rendering none, because a plausible list belonging to something else is
105/// indistinguishable from this one's.
106///
107/// The key is the run's workspace, on the precedent [`Asker::ask_in`] set for
108/// exactly this shape: one agent, many conversations, and the jail as the only
109/// thing in scope at call time that says which is which. Two runs sharing a
110/// workspace share a list, which is right — that is the same conversation
111/// resumed, not two.
112///
113/// [`Asker::ask_in`]: super::ask::Asker::ask_in
114#[derive(Default)]
115pub struct TodoTool {
116 lists: Mutex<HashMap<PathBuf, Tracked>>,
117}
118
119/// One conversation's plan, and what the harness knows about how its steps
120/// went.
121///
122/// The record beside the plan exists because **a step is closed by the model
123/// and nothing checked it** (`docs/GOAL-SYSTEM-DESIGN.md` §5.5). Checking
124/// needs two boundaries — where a step started and where it was called done —
125/// and only this tool sees both: the loop owns the run's trace and stamps the
126/// counters, but a *span* is a fact about a plan, which is the one thing the
127/// loop must never learn about.
128///
129/// It is deliberately not a store. Nothing here survives the process, nothing
130/// is written down, and a mark that goes missing costs one silent completion —
131/// which is the right price, because the alternative is a second source of
132/// truth about a plan whose record is already the transcript (D15).
133#[derive(Default)]
134struct Tracked {
135 plan: Plan,
136 /// Where each started step's span began, keyed by the item's content.
137 ///
138 /// Content is the only handle a plan write offers — items carry no id, and
139 /// giving them one would cost a field the model must repeat correctly on
140 /// every write of the tool whose whole job is being cheap to keep updated.
141 /// So a step whose *wording* is rewritten loses its mark and is appraised
142 /// as nothing, which is the safe direction: silence, never a finding about
143 /// a span that is not the one measured.
144 started: HashMap<String, Mark>,
145 /// Steps already reported on, so a second identical reading escalates
146 /// instead of asking for the same revision again (§5.5's bound).
147 flagged: std::collections::HashSet<String>,
148 /// How many times this tool has been called for this plan — every call,
149 /// including one whose input this tool rejects. A rejected write still
150 /// touches nothing but this tool's own state, so it is bookkeeping too;
151 /// see [`Tracked::observe`].
152 ///
153 /// Subtracted from every span: rewriting the list is bookkeeping, and a
154 /// model that revises its plan three times mid-step would otherwise show
155 /// three calls of "work" for a step where nothing happened.
156 own_calls: u32,
157 /// The outcome of the most recent call that was *not* this tool touching
158 /// its own state, as of the last time [`Tracked::observe`] ran.
159 ///
160 /// `Work::last` cannot answer this: it is the raw trace's most recent
161 /// entry, which is this tool's own call whenever one lands last. Tracked
162 /// incrementally because a scalar count of "how many calls were ours"
163 /// cannot say *which* position in the sequence they occupied.
164 last_real: Option<crate::step::Outcome>,
165 /// What `work.calls` will read once *this* call's own trace entry lands —
166 /// set at the end of every [`Tracked::observe`]. The next call compares
167 /// its own `work.calls` against this to tell whether anything landed in
168 /// between besides our own entry.
169 next_own_position: Option<u32>,
170 /// Steps that landed cleanly, most recent last: `(content, calls)`. The
171 /// baseline `step::escalation_candidate`'s span-outlier trigger compares
172 /// against, and the siblings its escalation shows the model for context.
173 ///
174 /// Bounded at [`COMPLETED_HISTORY_CAP`] — a long resumed conversation
175 /// revises its plan many times, and this is a rolling sense of "how big
176 /// are this plan's steps", not a full history.
177 completed: Vec<(String, u32)>,
178}
179
180/// See [`Tracked::completed`].
181const COMPLETED_HISTORY_CAP: usize = 20;
182
183/// Where one step's span starts, in the two units it has to be measured in.
184#[derive(Clone, Copy)]
185struct Mark {
186 work: crate::step::Work,
187 own_calls: u32,
188}
189
190impl Tracked {
191 /// Register one call to this tool, before anything about its input is
192 /// known — a call this tool goes on to reject is still this tool
193 /// touching its own state and nothing else, so it counts as bookkeeping
194 /// exactly like a successful write. Returns `own_calls` as it stood
195 /// *before* this call, which is what a mark taken during this same call
196 /// must record and what a span completing during it must subtract.
197 ///
198 /// Also brings `last_real` current. `next_own_position` says what
199 /// `work.calls` will read once this call's own entry **and every
200 /// approved sibling in its batch** has landed — `work.in_flight` is
201 /// exactly that sibling count, since this same `Work` snapshot is
202 /// shared by every call in one turn and predates all of them landing.
203 /// Missing that term made the guard assume every one of this tool's own
204 /// calls was the only call in its batch: the model doing real work and
205 /// ticking the box in the same turn — the shape `in_flight` exists
206 /// for — advanced `work.calls` by the whole batch size at the next
207 /// check, the equality failed, and `last_real` was overwritten with
208 /// whatever landed last, which was this tool's own entry whenever
209 /// `todo` came last in the batch (the natural order: do the work, then
210 /// tick the box).
211 ///
212 /// The comparison side strips `work.denied`: a call denied in the
213 /// *same* turn as this one lands in `trace` ahead of this call (the
214 /// gate loop pushes a denial immediately, before dispatching what it
215 /// approved), so it is already counted in `work.calls` the instant this
216 /// call sees it — not something to predict for later. Counting it as
217 /// "something new happened" would let an unrelated sibling's refusal
218 /// overwrite `last_real` with `Refused`, which is exactly the
219 /// misattribution `span.denied` exists to suppress a different way;
220 /// this guard must not re-introduce it through `last_real` instead.
221 ///
222 /// If the (denial-stripped) reading handed to the *next* call doesn't
223 /// match the prediction, something else landed in between (or this is
224 /// the first call ever, or the run restarted) and the fresh `work.last`
225 /// is real work rather than our own echo. If it does match, nothing but
226 /// our own batch happened and `last_real` carries over unchanged.
227 ///
228 /// **Accepted residual, in the safe direction.** A match means "only
229 /// this batch's siblings landed," but a sibling's *outcome* is still
230 /// invisible to this tool: `Work` is deliberately a handful of integers
231 /// rather than a list, so nothing here can tell whether a failing
232 /// sibling landed before or after this tool's own entry within the
233 /// batch — that depends on the order the model happened to list the
234 /// calls in, which this tool cannot see and must not guess at. When the
235 /// sibling lands after, its failure is swallowed the same way an
236 /// unbatched one is, one layer removed. This is the false-negative
237 /// direction the module doc names as the one to prefer: a masked
238 /// failure costs a missed finding, where guessing at an unknowable order
239 /// risks the manufactured-failure false positive this fix exists to
240 /// close. `in_flight` already suppresses the finding for *this* span
241 /// while the batch is still forming; what survives past it is the
242 /// span's own last-known reading, not a reconstruction of the batch.
243 fn observe(&mut self, work: Option<crate::step::Work>) -> u32 {
244 let before = self.own_calls;
245 if let Some(work) = work {
246 let settled = work.calls.saturating_sub(work.denied);
247 if self.next_own_position != Some(settled) {
248 self.last_real = work.last;
249 }
250 self.next_own_position = Some(work.calls + work.in_flight + 1);
251 }
252 self.own_calls += 1;
253 before
254 }
255
256 /// Fold one plan write in, and say what the steps that just finished
257 /// actually did.
258 ///
259 /// `work` is the run's counters as of *before* this turn's batch — which
260 /// is also before this call itself reaches the trace. `own_calls_before`
261 /// and `last_real` are [`Tracked::observe`]'s account of this same call,
262 /// taken at the same instant, so their difference against a mark is a
263 /// span. Anything unknown produces no line at all: a step never seen in
264 /// progress, a run whose counters restarted, a context nobody stamped.
265 fn advance(
266 &mut self,
267 next: Plan,
268 work: Option<crate::step::Work>,
269 own_calls_before: u32,
270 last_real: Option<crate::step::Outcome>,
271 step_escalation: Option<
272 &std::sync::Arc<std::sync::Mutex<Option<crate::step::StepEscalation>>>,
273 >,
274 ) -> Vec<String> {
275 let before: HashMap<&str, Status> = self
276 .plan
277 .items
278 .iter()
279 .map(|i| (i.content.as_str(), i.status))
280 .collect();
281
282 // `live` is computed here, before the item loop, rather than only
283 // after it (its other use, in the sweep below) — a write that both
284 // trims finished steps out of the plan *and* lands a new one in the
285 // same call needs the pruned baseline for that landing's own
286 // comparison, not just for steps *after* this write. Computing it
287 // once and reading it twice also means the sweep below can't drift
288 // from what the snapshot used.
289 let live: std::collections::HashSet<&str> =
290 next.items.iter().map(|i| i.content.as_str()).collect();
291
292 // Snapshotted once, before this batch's own completions can reach
293 // it: a model that marks two steps `completed` in one write (the
294 // tool's own docstring discourages this — "as soon as it is done
295 // rather than in a batch at the end" — but does not prevent it)
296 // would otherwise have the *second* item's comparison silently
297 // contaminated by the *first* item's own call count, pushed onto
298 // `self.completed` earlier in this same loop. Every candidate this
299 // batch produces is judged against the plan's history as it stood
300 // before the batch, never against a sibling landing beside it —
301 // and filtered by `live` for the same reason the sweep below prunes
302 // it: a step this same write is dropping from the plan is not "the
303 // plan's other completed steps" either, even though the retain
304 // below has not run yet.
305 let completed_before_this_batch: Vec<(String, u32)> = self
306 .completed
307 .iter()
308 .filter(|(k, _)| live.contains(k.as_str()))
309 .cloned()
310 .collect();
311
312 let mut lines = Vec::new();
313 for item in &next.items {
314 let was = before.get(item.content.as_str()).copied();
315 match item.status {
316 // Started, or restarted after a revision — either way the span
317 // begins now. A revised step measured from its *first* start
318 // would carry the failed attempt's work into the retry's
319 // verdict.
320 Status::InProgress if was != Some(Status::InProgress) => {
321 if let Some(work) = work {
322 self.started.insert(
323 item.content.clone(),
324 Mark {
325 work,
326 own_calls: own_calls_before,
327 },
328 );
329 }
330 }
331 Status::Completed if was != Some(Status::Completed) => {
332 let Some(mark) = self.started.remove(&item.content) else {
333 continue;
334 };
335 let Some(span) = work.and_then(|w| {
336 w.since(
337 mark.work,
338 own_calls_before.saturating_sub(mark.own_calls),
339 last_real,
340 )
341 }) else {
342 continue;
343 };
344 let finding = crate::step::appraise(span);
345 match finding.line(&item.content, self.flagged.contains(&item.content)) {
346 Some(line) => {
347 self.flagged.insert(item.content.clone());
348 lines.push(line);
349 }
350 // It landed, so the next thing to go wrong here is a
351 // first time again. Not while siblings are in flight
352 // or one was denied this turn: both read as landed
353 // because nothing is known yet or nothing here is
354 // attributable, neither of which is the same as
355 // having gone well.
356 None if span.in_flight == 0 && span.denied == 0 => {
357 self.flagged.remove(&item.content);
358 // A genuinely clean landing — not an ambiguous
359 // batch `appraise` defaulted to `Landed` — is a
360 // baseline worth remembering, and a candidate
361 // worth a second opinion (§5.5's escalation).
362 if let Some(slot) = step_escalation {
363 // `completed_before_this_batch` is filtered
364 // by `live`, which this item's own content
365 // is a member of — it is in `next.items`,
366 // being completed right now — so the batch
367 // filter alone does not exclude it. A step
368 // that completed once, was reopened, and is
369 // completing again here would otherwise see
370 // its own pre-revision span as one of "the
371 // plan's other completed steps": the
372 // opposite of round 12's fix, but the same
373 // bug — a step being counted as its own
374 // sibling — reached through the batch
375 // snapshot instead of the live push.
376 let siblings_excluding_self: Vec<(String, u32)> =
377 completed_before_this_batch
378 .iter()
379 .filter(|(k, _)| k != &item.content)
380 .cloned()
381 .collect();
382 if let Some(escalation) = crate::step::escalation_candidate(
383 span,
384 &item.content,
385 &siblings_excluding_self,
386 ) {
387 // First candidate this batch wins. The
388 // slot is always drained once per turn
389 // (`agent.rs`'s read-clear-call-fold), so
390 // it is empty going into this call —
391 // `is_none` here means "nothing else in
392 // *this* batch has claimed it yet", not
393 // "an older, unconsumed candidate is
394 // stale." Two steps completing in one
395 // write is rare and the mechanism holds
396 // exactly one candidate at a time by
397 // design (`compact_requested`'s own
398 // shape); silently letting a later item
399 // overwrite an earlier one would make
400 // which candidate survives an accident of
401 // iteration order rather than a choice.
402 let mut guard = slot.lock().unwrap();
403 if guard.is_none() {
404 *guard = Some(escalation);
405 }
406 }
407 }
408 // Dedupe on content first, same as `started`
409 // (a `HashMap`, so a revision's fresh mark
410 // already replaces rather than doubles up): a
411 // step revised and completed twice would
412 // otherwise contribute two entries under the
413 // same name, inflating `completed.len()` and the
414 // mean it feeds, and letting a step be listed as
415 // its own sibling. Keeps the latest span, and
416 // moves the entry to the end so "most recent
417 // last" still holds for a step that was redone.
418 self.completed.retain(|(k, _)| k != &item.content);
419 self.completed.push((item.content.clone(), span.calls));
420 if self.completed.len() > COMPLETED_HISTORY_CAP {
421 self.completed.remove(0);
422 }
423 }
424 None => {}
425 }
426 }
427 _ => {}
428 }
429 }
430
431 // A mark on an item the plan no longer holds describes work nobody is
432 // doing, and would otherwise sit in the map for the life of the
433 // conversation waiting for a step of the same wording to be re-added.
434 // `completed` gets the same sweep: unlike `started`/`flagged`, it is
435 // read by `escalation_candidate` as "the plan's other completed
436 // steps", and `TodoTool`'s lists are keyed by workspace rather than
437 // conversation — so without this, a wholesale plan rewrite (or a
438 // second conversation reusing the workspace) leaves stale entries
439 // from a plan that no longer exists as the mean a new plan's steps
440 // are judged against. Bounding `COMPLETED_HISTORY_CAP` protects
441 // against unbounded growth; it does not scope the history to the
442 // plan that is live now. `live` itself is the same set the snapshot
443 // above filtered by — computed once, at the top of this call.
444 self.started.retain(|k, _| live.contains(k.as_str()));
445 self.flagged.retain(|k| live.contains(k.as_str()));
446 self.completed.retain(|(k, _)| live.contains(k.as_str()));
447 drop(live);
448
449 self.plan = next;
450 lines
451 }
452}
453
454impl TodoTool {
455 pub fn new() -> Self {
456 Self::default()
457 }
458
459 /// Replace one run's list wholesale — the resume path.
460 ///
461 /// Only [`rehydrate`](Self::rehydrate) has any business calling this: a
462 /// list set by anything other than the model's own `todo` write, or a
463 /// faithful restoration of one, is a second author of state the tool is
464 /// supposed to own.
465 ///
466 /// A fresh record, not a plan swapped into the old one: the spans this
467 /// tool measures are counted from a run's trace, and a plan restored from
468 /// a transcript was written by a process whose counters are gone. Keeping
469 /// the marks would measure the resumed run's work against the killed
470 /// one's — the exact wrong-units mistake rung 4 made reading headroom off
471 /// one run's outcome for a whole episode.
472 pub fn set_plan_in(&self, workspace: &Path, plan: Plan) {
473 self.lists.lock().unwrap().insert(
474 workspace.into(),
475 Tracked {
476 plan,
477 ..Tracked::default()
478 },
479 );
480 }
481
482 /// Restore a resumed conversation's plan from its own transcript.
483 ///
484 /// Returns the number of items restored, or `None` when the transcript
485 /// held no plan. **D15.** The list lives in memory, which was fine while a
486 /// run ended when its conversation did; a task outlives its run by
487 /// construction (D13), and on resume the *model* re-reads its plan from
488 /// the transcript echo while a UI polling this handle sees nothing. The
489 /// model knows where it got to and the card shows no progress — D5's
490 /// divergence, arriving from the side the harness controls.
491 ///
492 /// Deliberately not a stored copy beside the session. The transcript is
493 /// already the record, and a second copy is the thing that can disagree
494 /// with it — the objection that keeps a mecha-side store of task runs from
495 /// existing, and the reason the TUI reads a trigger's last answer from the
496 /// session file rather than caching it.
497 pub fn rehydrate(&self, workspace: &Path, messages: &[Message]) -> Option<usize> {
498 let plan = Self::plan_from_transcript(messages)?;
499 let n = plan.items.len();
500 self.set_plan_in(workspace, plan);
501 Some(n)
502 }
503
504 /// The most recent plan a transcript records, from either of the two
505 /// places one can survive.
506 ///
507 /// Walked newest-first, and the order does the arbitration for free: a
508 /// `todo` call made after a compaction is found before the carried block,
509 /// which sits in the head message and is therefore reached last.
510 ///
511 /// Two sources rather than one, because they cover disjoint cases. The
512 /// **tool input** is structured and exact, and is what an uncompacted
513 /// transcript holds. But a compaction *removes* those blocks — `rebuild`
514 /// keeps the rendered list in the carried-state block instead — and a run
515 /// long enough to compact is precisely the long-running delegation this
516 /// exists for, so reading only the inputs would fail on the motivating
517 /// case and succeed on the easy one.
518 ///
519 /// A write whose result was an error restored nothing at the time and
520 /// restores nothing now: the tool rejected it, so the list it names never
521 /// existed.
522 pub fn from_transcript(messages: &[Message]) -> Option<Vec<TodoItem>> {
523 Self::plan_from_transcript(messages).map(|p| p.items)
524 }
525
526 /// The same walk, keeping what the plan serves.
527 pub fn plan_from_transcript(messages: &[Message]) -> Option<Plan> {
528 let failed: std::collections::HashSet<&str> = messages
529 .iter()
530 .flat_map(|m| m.content.iter())
531 .filter_map(|b| match b {
532 Block::ToolResult {
533 tool_use_id,
534 is_error: true,
535 ..
536 } => Some(tool_use_id.as_str()),
537 _ => None,
538 })
539 .collect();
540
541 for msg in messages.iter().rev() {
542 for block in msg.content.iter().rev() {
543 match block {
544 Block::ToolUse { id, name, input }
545 if name == "todo" && !failed.contains(id.as_str()) =>
546 {
547 if let Some(items) = input.get("items") {
548 if let Ok(items) =
549 serde_json::from_value::<Vec<TodoItem>>(items.clone())
550 {
551 // Lenient on the way in: this is a record, and
552 // a kind this binary has not heard of must
553 // cost the reference rather than the plan.
554 let goal = input
555 .get("serves")
556 .and_then(Value::as_str)
557 .and_then(GoalRef::parse_lenient);
558 return Some(Plan { goal, items });
559 }
560 }
561 }
562 Block::Text { text } if text.trim_start().starts_with(CARRIED_HEADER) => {
563 let plan = Self::parse_carried(text);
564 if !plan.items.is_empty() {
565 return Some(plan);
566 }
567 }
568 _ => {}
569 }
570 }
571 }
572 None
573 }
574
575 /// The `## todo` section of a carried-state block, back into items.
576 ///
577 /// The inverse of [`render`](Self::render), and a round-trip test says so.
578 /// Stops at the next `## ` because the block carries every stateful tool's
579 /// section, not only this one.
580 fn parse_carried(text: &str) -> Plan {
581 let mut lines = text.lines().skip_while(|l| l.trim() != "## todo");
582 if lines.next().is_none() {
583 return Plan::default();
584 }
585 let section: Vec<&str> = lines
586 .take_while(|l| !l.trim_start().starts_with("## "))
587 .collect();
588 // Anchored to the first non-empty line, because `render` always writes
589 // it there. Scanning the whole section would let an item whose
590 // *content* contains a line beginning `serves task:…` supply the
591 // plan's goal — free text deciding what the run is for.
592 let goal = section
593 .iter()
594 .find(|l| !l.trim().is_empty())
595 .and_then(|l| l.trim().strip_prefix(SERVES))
596 .and_then(GoalRef::parse_lenient);
597 let items = section
598 .iter()
599 .filter_map(|line| {
600 let line = line.trim();
601 let (marker, rest) = line.split_at(line.char_indices().nth(3)?.0);
602 let status = match marker {
603 "[ ]" => Status::Pending,
604 "[~]" => Status::InProgress,
605 "[x]" => Status::Completed,
606 _ => return None,
607 };
608 let content = rest.trim();
609 (!content.is_empty()).then(|| TodoItem {
610 content: content.to_string(),
611 status,
612 })
613 })
614 .collect();
615 Plan { goal, items }
616 }
617
618 /// One run's list, for a UI that wants to render progress live.
619 ///
620 /// An absent key is an empty list rather than an error: a conversation
621 /// that has not written a plan and one that never will look the same from
622 /// here, and both render as no pane.
623 pub fn items_in(&self, workspace: &Path) -> Vec<TodoItem> {
624 self.lists
625 .lock()
626 .unwrap()
627 .get(workspace)
628 .map(|t| t.plan.items.clone())
629 .unwrap_or_default()
630 }
631
632 /// What this run's plan serves, if it said.
633 pub fn goal_in(&self, workspace: &Path) -> Option<GoalRef> {
634 self.lists.lock().unwrap().get(workspace)?.plan.goal.clone()
635 }
636
637 fn render(plan: &Plan) -> String {
638 if plan.items.is_empty() {
639 // Still say what it was for. Carrying it across a compaction needs
640 // items — `carried_state` treats an empty section as "the plan is
641 // finished" — but the echo is what the model reads *this* turn,
642 // and a goal it cannot see is one it cannot re-state.
643 return match &plan.goal {
644 Some(goal) => format!("{SERVES} {goal}\n(the list is empty)"),
645 None => "(the list is empty)".to_string(),
646 };
647 }
648 let done = plan
649 .items
650 .iter()
651 .filter(|i| i.status == Status::Completed)
652 .count();
653 // Above the list, not beside an item: what the steps are *for* is the
654 // half a summariser drops, and it has to be the first thing read back.
655 let mut out = String::new();
656 if let Some(goal) = &plan.goal {
657 out.push_str(&format!("{SERVES} {goal}\n"));
658 }
659 out.push_str(&format!("{done}/{} done\n", plan.items.len()));
660 for item in &plan.items {
661 out.push_str(&format!("{} {}\n", item.status.marker(), item.content));
662 }
663 out
664 }
665}
666
667#[async_trait]
668impl Tool for TodoTool {
669 fn name(&self) -> &str {
670 "todo"
671 }
672
673 fn description(&self) -> &str {
674 "Record and update your task list for multi-step work. If a task will take more \
675 than three tool calls, call this FIRST, before any other tool, and keep the list \
676 updated as you work. Pass the COMPLETE list every time — it replaces what was \
677 there, so include finished items with status `completed`. Exactly one item should \
678 be `in_progress` at a time, and an item should be marked `completed` as soon as \
679 it is done rather than in a batch at the end. If the work serves a task on \
680 the board, pass `serves` — and pass it on every write, like `items`, \
681 because both replace what was there. Skip this tool only for work of \
682 one or two steps."
683 }
684
685 fn input_schema(&self) -> Value {
686 json!({
687 "type": "object",
688 "properties": {
689 "items": {
690 "type": "array",
691 "description": "The complete task list, in order.",
692 "items": {
693 "type": "object",
694 "properties": {
695 "content": {
696 "type": "string",
697 "description": "One concrete step, phrased as an action."
698 },
699 "status": {
700 "type": "string",
701 "enum": ["pending", "in_progress", "completed"]
702 }
703 },
704 "required": ["content", "status"]
705 }
706 },
707 "serves": {
708 "type": "string",
709 "description": "Optional. What this whole plan is working toward, as \
710 `task:<id>` for a task on the board. Pass it on every \
711 write, like `items` — it is replaced, not merged."
712 }
713 },
714 "required": ["items"]
715 })
716 }
717
718 fn read_only(&self) -> bool {
719 // Touches nothing outside the agent's own head.
720 true
721 }
722
723 /// The list survives a compaction verbatim.
724 ///
725 /// The model re-reads its plan every turn through the echo in the last
726 /// `todo` result — which is a *message*, and therefore exactly the kind of
727 /// thing a compaction summarises away. That made this tool's whole
728 /// mechanism quietly conditional on the transcript never getting long,
729 /// which is the one situation the list matters most in: the measured
730 /// failure of summarisation is that it keeps what is true and drops how
731 /// far you got, and this list is nothing but how far you got.
732 ///
733 /// Rendered rather than summarised, because the tool holds the exact
734 /// current answer and a summariser would only be a lossy path to a worse
735 /// copy of it.
736 fn carried_state(&self, ctx: &ToolCtx) -> Option<CarriedState> {
737 let lists = self.lists.lock().unwrap();
738 let plan = &lists.get(&ctx.workspace)?.plan;
739 // An empty list is genuinely nothing to carry, and an empty section in
740 // the prompt reads as "the plan is finished" rather than "there was
741 // never a plan".
742 if plan.items.is_empty() {
743 return None;
744 }
745 Some(CarriedState {
746 label: "todo".into(),
747 body: Self::render(plan),
748 })
749 }
750
751 /// `/clear` and a finished batch item both mean "this conversation is
752 /// over", and the plan is conversation state like any other.
753 ///
754 /// It went unimplemented while the list was agent-wide, when the same
755 /// omission merely meant a stale pane. Keyed by workspace it is worse: a
756 /// cleared conversation and the next one share a jail, so yesterday's plan
757 /// would survive into today's run *and* be spliced into its compaction by
758 /// `carried_state` — which is precisely the "plausible list belonging to
759 /// something else" the keying was introduced to prevent, arriving through
760 /// the one door the keying does not close.
761 ///
762 /// Clears every workspace rather than one, because the trait method says
763 /// nothing about which conversation ended and the registry calls it on a
764 /// front-end that has exactly one. That is also what bounds the map: a
765 /// long-lived process minting a new session key per conversation
766 /// (`serve::session_workspace`) would otherwise accumulate one entry per
767 /// session for the life of the process.
768 fn forget_conversation_state(&self) {
769 self.lists.lock().unwrap().clear();
770 }
771
772 async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
773 // Registered before validation, and unconditionally: a write this
774 // tool goes on to reject below is still this tool touching its own
775 // state and nothing else, so it must count as bookkeeping exactly
776 // like a successful one — never as work that failed.
777 let (own_calls_before, last_real) = {
778 let mut lists = self.lists.lock().unwrap();
779 let tracked = lists.entry(ctx.workspace.clone()).or_default();
780 let own_calls_before = tracked.observe(ctx.work);
781 (own_calls_before, tracked.last_real)
782 };
783
784 let Some(raw) = input.get("items").and_then(Value::as_array) else {
785 return Ok(ToolOutput::err(
786 "`items` must be an array of {content, status}",
787 ));
788 };
789
790 let mut items = Vec::with_capacity(raw.len());
791 for (i, entry) in raw.iter().enumerate() {
792 let Some(content) = entry.get("content").and_then(Value::as_str) else {
793 return Ok(ToolOutput::err(format!("item {i} has no `content` string")));
794 };
795 let status = match entry.get("status").and_then(Value::as_str) {
796 Some("pending") => Status::Pending,
797 Some("in_progress") => Status::InProgress,
798 Some("completed") => Status::Completed,
799 other => {
800 return Ok(ToolOutput::err(format!(
801 "item {i} has status {other:?}; expected pending, in_progress, or completed"
802 )))
803 }
804 };
805 items.push(TodoItem {
806 content: content.to_string(),
807 status,
808 });
809 }
810
811 // Nudge rather than reject: two items in flight is a mild smell, not an
812 // error, and refusing the write would lose the update entirely.
813 let in_progress = items
814 .iter()
815 .filter(|i| i.status == Status::InProgress)
816 .count();
817 let mut note = String::new();
818 if in_progress > 1 {
819 note = format!(
820 "\n(note: {in_progress} items are in_progress — finish one before starting another)"
821 );
822 }
823
824 // Strict on the way in, unlike every reader of a record: the model can
825 // fix this on the next call, and a silently dropped reference leaves a
826 // plan claiming to serve something it does not.
827 let goal = match input.get("serves") {
828 None | Some(Value::Null) => None,
829 Some(value) => {
830 // Present but not a string is an error, not an absence. The
831 // object spelling — `{"kind": "task", "id": …}` — is the one a
832 // model reaches for, and dropping it silently would leave a
833 // plan claiming to serve nothing while the model believed it
834 // had said so.
835 let Some(raw) = value.as_str() else {
836 return Ok(ToolOutput::err(
837 "`serves` must be a string like `task:<id>`",
838 ));
839 };
840 // An empty string is how a model spells an omitted optional
841 // field. Refusing it would throw away an otherwise-valid plan
842 // update over a field that was not being used.
843 if raw.trim().is_empty() {
844 None
845 } else {
846 match raw.parse::<GoalRef>() {
847 Ok(goal) => Some(goal),
848 Err(e) => return Ok(ToolOutput::err(format!("`serves`: {e}"))),
849 }
850 }
851 }
852 };
853
854 let plan = Plan { goal, items };
855 let rendered = Self::render(&plan);
856 // What the steps that just finished actually did, against the run's
857 // own record of what it has done. The harness computes the fact; the
858 // plan action it argues for — accept, revise the step, revise the
859 // plan, escalate — is the model's next call, because the plan is the
860 // model's. §5.5.
861 let findings = self
862 .lists
863 .lock()
864 .unwrap()
865 .entry(ctx.workspace.clone())
866 .or_default()
867 .advance(
868 plan,
869 ctx.work,
870 own_calls_before,
871 last_real,
872 ctx.step_escalation.as_ref(),
873 );
874 let findings = match findings.is_empty() {
875 true => String::new(),
876 false => format!("\n\n{}", findings.join("\n")),
877 };
878 // The headroom reading, on the one result where it changes a
879 // decision. Not the turn tail and not the system prompt: the tail
880 // would leave one stale reading per turn in an append-only transcript
881 // — the distractor shape `evict_superseded_results` exists to remove —
882 // and the system prompt sits inside the cached prefix, so a per-turn
883 // value there re-pays the whole thing including the tool specs. Here
884 // it costs nothing on any turn that does not touch the plan, and the
885 // accumulation is bounded by plan revisions rather than by turns.
886 //
887 // Bounded, not absent: an earlier `todo` result is *not* generally
888 // superseded by this one. `compact::target_of` falls through to
889 // `{name}\0{input}` for a call with no `path`, so two `todo` calls are
890 // the same target only when their item lists are byte-identical — and a
891 // second `todo` call exists precisely to change the list. So a run that
892 // revises its plan ten times carries ten readings until a compaction
893 // thins them. That is the same distractor shape as the turn tail, at a
894 // far lower rate, which is the trade being made and not a case of
895 // avoiding it.
896 //
897 // Absent when the run has no compaction threshold or has not sent a
898 // request yet — a missing line is right where there is no measurement,
899 // and inventing one would put a guess in the one place every other
900 // number is measured.
901 let context = match &ctx.context {
902 Some(f) => format!("\n\n{f}"),
903 None => String::new(),
904 };
905 Ok(ToolOutput::ok(format!(
906 "{rendered}{note}{findings}{context}"
907 )))
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 #[tokio::test]
916 async fn writing_the_list_echoes_it_back_with_progress() {
917 let tool = TodoTool::new();
918 let ctx = ToolCtx::default();
919 let out = tool
920 .call(
921 json!({"items": [
922 {"content": "read the config", "status": "completed"},
923 {"content": "fix the port", "status": "in_progress"},
924 {"content": "run the tests", "status": "pending"}
925 ]}),
926 &ctx,
927 )
928 .await
929 .unwrap();
930
931 assert!(!out.is_error);
932 assert!(out.content.starts_with("1/3 done"));
933 assert!(out.content.contains("[x] read the config"));
934 assert!(out.content.contains("[~] fix the port"));
935 assert!(out.content.contains("[ ] run the tests"));
936 assert_eq!(tool.items_in(&ctx.workspace).len(), 3);
937 }
938
939 #[tokio::test]
940 async fn the_list_is_replaced_not_appended() {
941 let tool = TodoTool::new();
942 let ctx = ToolCtx::default();
943 tool.call(
944 json!({"items": [{"content": "a", "status": "pending"}]}),
945 &ctx,
946 )
947 .await
948 .unwrap();
949 tool.call(
950 json!({"items": [{"content": "b", "status": "pending"}]}),
951 &ctx,
952 )
953 .await
954 .unwrap();
955
956 let items = tool.items_in(&ctx.workspace);
957 assert_eq!(items.len(), 1, "a write replaces the whole list");
958 assert_eq!(items[0].content, "b");
959 }
960
961 #[tokio::test]
962 async fn a_plan_can_name_what_it_serves_and_echoes_it_above_the_list() {
963 let tool = TodoTool::new();
964 let ctx = ToolCtx::default();
965 let out = tool
966 .call(
967 json!({
968 "items": [{"content": "draft the reply", "status": "in_progress"}],
969 "serves": "task:01J8ZK",
970 }),
971 &ctx,
972 )
973 .await
974 .unwrap();
975 assert!(!out.is_error);
976 // Above the list, because the echo is what the model re-reads every
977 // turn and what survives a compaction.
978 assert!(
979 out.content.starts_with("serves task:01J8ZK\n"),
980 "{}",
981 out.content
982 );
983 assert_eq!(
984 tool.goal_in(&ctx.workspace),
985 Some(GoalRef::Task("01J8ZK".into()))
986 );
987 }
988
989 /// The model-facing direction is strict, the opposite of every reader of a
990 /// record. A dropped reference would leave a plan claiming to serve
991 /// something it does not, and the model can fix this on the next call.
992 #[tokio::test]
993 async fn a_malformed_goal_is_reported_rather_than_silently_dropped() {
994 let tool = TodoTool::new();
995 let ctx = ToolCtx::default();
996 let out = tool
997 .call(
998 json!({
999 "items": [{"content": "a", "status": "pending"}],
1000 "serves": "epic:7",
1001 }),
1002 &ctx,
1003 )
1004 .await
1005 .unwrap();
1006 assert!(out.is_error);
1007 assert!(
1008 out.content.contains("not a kind of goal"),
1009 "{}",
1010 out.content
1011 );
1012 assert!(
1013 tool.items_in(&ctx.workspace).is_empty(),
1014 "a rejected write changes nothing"
1015 );
1016 }
1017
1018 #[tokio::test]
1019 async fn a_plan_that_serves_nothing_renders_no_goal_line() {
1020 let tool = TodoTool::new();
1021 let ctx = ToolCtx::default();
1022 let out = tool
1023 .call(
1024 json!({"items": [{"content": "a", "status": "pending"}]}),
1025 &ctx,
1026 )
1027 .await
1028 .unwrap();
1029 assert!(!out.is_error);
1030 assert!(out.content.starts_with("0/1 done"), "{}", out.content);
1031 assert_eq!(tool.goal_in(&ctx.workspace), None);
1032 }
1033
1034 /// Present but not a string is an error, not an absence. The object
1035 /// spelling is the one a model reaches for, and dropping it silently would
1036 /// leave a plan serving nothing while the model believed it had said so.
1037 #[tokio::test]
1038 async fn a_non_string_goal_is_reported_rather_than_silently_dropped() {
1039 let tool = TodoTool::new();
1040 let ctx = ToolCtx::default();
1041 let out = tool
1042 .call(
1043 json!({
1044 "items": [{"content": "a", "status": "pending"}],
1045 "serves": {"kind": "task", "id": "01J8ZK"},
1046 }),
1047 &ctx,
1048 )
1049 .await
1050 .unwrap();
1051 assert!(out.is_error, "{}", out.content);
1052 assert!(out.content.contains("must be a string"), "{}", out.content);
1053 assert!(tool.items_in(&ctx.workspace).is_empty());
1054 }
1055
1056 /// An empty string is how a model spells an unused optional field.
1057 /// Refusing it would discard an otherwise-valid plan update over a field
1058 /// that was not being used.
1059 #[tokio::test]
1060 async fn an_empty_goal_means_omitted_and_does_not_cost_the_write() {
1061 let tool = TodoTool::new();
1062 let ctx = ToolCtx::default();
1063 let out = tool
1064 .call(
1065 json!({
1066 "items": [{"content": "a", "status": "pending"}],
1067 "serves": "",
1068 }),
1069 &ctx,
1070 )
1071 .await
1072 .unwrap();
1073 assert!(!out.is_error, "{}", out.content);
1074 assert_eq!(tool.items_in(&ctx.workspace).len(), 1, "the plan was kept");
1075 assert_eq!(tool.goal_in(&ctx.workspace), None);
1076 }
1077
1078 /// The echo is what the model reads this turn. A goal it cannot see is one
1079 /// it cannot re-state on the next write.
1080 #[tokio::test]
1081 async fn an_empty_list_still_says_what_it_was_for() {
1082 let tool = TodoTool::new();
1083 let ctx = ToolCtx::default();
1084 let out = tool
1085 .call(json!({"items": [], "serves": "task:01J8ZK"}), &ctx)
1086 .await
1087 .unwrap();
1088 assert!(!out.is_error);
1089 assert!(
1090 out.content.contains("serves task:01J8ZK"),
1091 "{}",
1092 out.content
1093 );
1094 }
1095
1096 #[tokio::test]
1097 async fn a_bad_status_is_reported_rather_than_silently_dropped() {
1098 let tool = TodoTool::new();
1099 let ctx = ToolCtx::default();
1100 let out = tool
1101 .call(json!({"items": [{"content": "a", "status": "done"}]}), &ctx)
1102 .await
1103 .unwrap();
1104 assert!(out.is_error);
1105 assert!(out.content.contains("expected pending"));
1106 assert!(
1107 tool.items_in(&ctx.workspace).is_empty(),
1108 "a rejected write changes nothing"
1109 );
1110 }
1111
1112 fn ctx_in(dir: &str) -> ToolCtx {
1113 ToolCtx {
1114 workspace: PathBuf::from(dir),
1115 ..Default::default()
1116 }
1117 }
1118
1119 /// The D14 property, and the reason this tool stopped holding one list.
1120 ///
1121 /// Fails on the old behaviour: a single `Mutex<Vec<TodoItem>>` returns
1122 /// b's plan for a's workspace, which is precisely the "plausible list
1123 /// belonging to something else" a UI cannot detect.
1124 #[tokio::test]
1125 async fn two_workspaces_keep_separate_lists() {
1126 let tool = TodoTool::new();
1127 let (a, b) = (ctx_in("/w/a"), ctx_in("/w/b"));
1128
1129 tool.call(
1130 json!({"items": [{"content": "a", "status": "pending"}]}),
1131 &a,
1132 )
1133 .await
1134 .unwrap();
1135 tool.call(
1136 json!({"items": [{"content": "b", "status": "pending"}]}),
1137 &b,
1138 )
1139 .await
1140 .unwrap();
1141
1142 let (ia, ib) = (tool.items_in(&a.workspace), tool.items_in(&b.workspace));
1143 assert_eq!(ia.len(), 1);
1144 assert_eq!(ib.len(), 1);
1145 assert_eq!(ia[0].content, "a", "b's write must not reach a's list");
1146 assert_eq!(ib[0].content, "b");
1147 }
1148
1149 use crate::message::Role;
1150
1151 fn todo_call(id: &str, items: &[(&str, &str)]) -> Message {
1152 let items: Vec<Value> = items
1153 .iter()
1154 .map(|(c, s)| json!({"content": c, "status": s}))
1155 .collect();
1156 Message {
1157 role: Role::Assistant,
1158 content: vec![Block::ToolUse {
1159 id: id.into(),
1160 name: "todo".into(),
1161 input: json!({ "items": items }),
1162 }],
1163 }
1164 }
1165
1166 fn result(id: &str, is_error: bool) -> Message {
1167 Message {
1168 role: Role::User,
1169 content: vec![Block::ToolResult {
1170 tool_use_id: id.into(),
1171 content: "ok".into(),
1172 is_error,
1173 }],
1174 }
1175 }
1176
1177 /// The ordinary resume: an uncompacted transcript still holds the
1178 /// structured input of the last write.
1179 #[tokio::test]
1180 async fn a_resumed_transcript_restores_the_last_plan() {
1181 let tool = TodoTool::new();
1182 let ws = PathBuf::from("/w/a");
1183 let msgs = vec![
1184 todo_call("t1", &[("first", "completed")]),
1185 result("t1", false),
1186 todo_call("t2", &[("first", "completed"), ("second", "in_progress")]),
1187 result("t2", false),
1188 ];
1189
1190 assert!(tool.items_in(&ws).is_empty(), "nothing before the resume");
1191 assert_eq!(tool.rehydrate(&ws, &msgs), Some(2));
1192
1193 let items = tool.items_in(&ws);
1194 assert_eq!(items[0].content, "first");
1195 assert_eq!(items[1].status, Status::InProgress);
1196 }
1197
1198 /// A write the tool rejected never changed the list, so restoring it would
1199 /// invent a plan the conversation never had.
1200 #[tokio::test]
1201 async fn a_rejected_write_is_not_restored() {
1202 let tool = TodoTool::new();
1203 let ws = PathBuf::from("/w/a");
1204 let msgs = vec![
1205 todo_call("t1", &[("real plan", "in_progress")]),
1206 result("t1", false),
1207 todo_call("t2", &[("rejected plan", "in_progress")]),
1208 result("t2", true),
1209 ];
1210
1211 tool.rehydrate(&ws, &msgs).unwrap();
1212 let items = tool.items_in(&ws);
1213 assert_eq!(items.len(), 1);
1214 assert_eq!(
1215 items[0].content, "real plan",
1216 "the rejected write is skipped"
1217 );
1218 }
1219
1220 /// The motivating case: a compaction removes the `todo` calls and keeps
1221 /// the rendered list in the carried block, so reading only tool inputs
1222 /// would fail on exactly the long-running delegation this is for.
1223 #[tokio::test]
1224 async fn a_compacted_transcript_restores_from_the_carried_block() {
1225 let tool = TodoTool::new();
1226 let ws = PathBuf::from("/w/a");
1227 // The real shape `compact::rebuild` produces: the original task, the
1228 // summary, and the carried state as three blocks on one head message —
1229 // not one block, which is what this test asserted until it failed and
1230 // sent me back to read `rebuild`.
1231 let head = Message {
1232 role: Role::User,
1233 content: vec![
1234 Block::text("the original task"),
1235 Block::text("\n\n[Earlier turns were compacted to fit the context window.]"),
1236 Block::text(format!(
1237 "\n\n{CARRIED_HEADER}\n\n## todo\n1/2 done\n [x] read the thread\n[~] draft the reply\n"
1238 )),
1239 ],
1240 };
1241
1242 assert_eq!(tool.rehydrate(&ws, &[head]), Some(2));
1243 let items = tool.items_in(&ws);
1244 assert_eq!(items[0].content, "read the thread");
1245 assert_eq!(items[0].status, Status::Completed);
1246 assert_eq!(items[1].content, "draft the reply");
1247 assert_eq!(items[1].status, Status::InProgress);
1248 }
1249
1250 /// Newest wins, and the walk order gives it for free: a write made after
1251 /// the compaction supersedes the block in the head message.
1252 #[tokio::test]
1253 async fn a_write_after_the_compaction_beats_the_carried_block() {
1254 let tool = TodoTool::new();
1255 let ws = PathBuf::from("/w/a");
1256 let msgs = vec![
1257 Message {
1258 role: Role::User,
1259 content: vec![Block::text(format!(
1260 "{CARRIED_HEADER}\n\n## todo\n0/1 done\n[ ] stale\n"
1261 ))],
1262 },
1263 todo_call("t9", &[("current", "in_progress")]),
1264 result("t9", false),
1265 ];
1266
1267 tool.rehydrate(&ws, &msgs).unwrap();
1268 assert_eq!(tool.items_in(&ws)[0].content, "current");
1269 }
1270
1271 /// `parse_carried` is the inverse of `render`, and drift between them
1272 /// would restore a plan that silently lost its statuses.
1273 #[test]
1274 fn rendering_and_parsing_round_trip() {
1275 let items = vec![
1276 TodoItem {
1277 content: "read the config".into(),
1278 status: Status::Completed,
1279 },
1280 TodoItem {
1281 content: "fix the port".into(),
1282 status: Status::InProgress,
1283 },
1284 TodoItem {
1285 content: "run the tests".into(),
1286 status: Status::Pending,
1287 },
1288 ];
1289 // With a goal, because *what the steps are for* is exactly the half a
1290 // summariser drops — carrying the list across a compaction and losing
1291 // what it serves would reproduce, one field down, the failure
1292 // `carried_state` exists to prevent.
1293 let plan = Plan {
1294 goal: Some(GoalRef::Task("01J8ZK".into())),
1295 items: items.clone(),
1296 };
1297 let block = format!("{CARRIED_HEADER}\n\n## todo\n{}\n", TodoTool::render(&plan));
1298 assert!(
1299 block.contains("serves task:01J8ZK"),
1300 "the goal is rendered above the list: {block}"
1301 );
1302
1303 let back = TodoTool::parse_carried(&block);
1304 assert_eq!(back, plan);
1305
1306 // And a plan that serves nothing round-trips as one, rather than
1307 // acquiring a reference on the way back.
1308 let bare = Plan { goal: None, items };
1309 let block = format!("{CARRIED_HEADER}\n\n## todo\n{}\n", TodoTool::render(&bare));
1310 assert_eq!(TodoTool::parse_carried(&block), bare);
1311 }
1312
1313 /// Free text must not be able to say what the run is for. `render` always
1314 /// writes the goal on the section's first line, so the parser anchors
1315 /// there — an unanchored scan let an item whose *content* held a line
1316 /// beginning `serves task:…` supply the plan's goal.
1317 #[test]
1318 fn an_item_whose_content_looks_like_a_goal_line_does_not_become_one() {
1319 let block =
1320 format!("{CARRIED_HEADER}\n\n## todo\n0/1 done\n[ ] paste this:\nserves task:99\n");
1321 assert_eq!(TodoTool::parse_carried(&block).goal, None);
1322 }
1323
1324 /// A record written by a newer binary naming a kind this one has never
1325 /// heard of costs the reference and nothing else. The opposite policy from
1326 /// the model-facing direction, which errors — see `goal`.
1327 #[test]
1328 fn a_carried_goal_of_an_unknown_kind_does_not_cost_the_plan() {
1329 let block = format!("{CARRIED_HEADER}\n\n## todo\nserves epic:7\n1/1 done\n[x] mine\n");
1330 let back = TodoTool::parse_carried(&block);
1331 assert_eq!(back.goal, None);
1332 assert_eq!(back.items.len(), 1, "the plan survives its unreadable goal");
1333 }
1334
1335 /// A block carries every stateful tool's section, so the walk must stop
1336 /// at the next heading rather than swallowing a neighbour's lines.
1337 #[test]
1338 fn a_neighbouring_carried_section_is_not_absorbed() {
1339 let block =
1340 format!("{CARRIED_HEADER}\n\n## todo\n1/1 done\n[x] mine\n\n## skill\n[x] not mine\n");
1341 let plan = TodoTool::parse_carried(&block);
1342 assert_eq!(plan.items.len(), 1);
1343 assert_eq!(plan.items[0].content, "mine");
1344 }
1345
1346 /// A transcript with no plan restores nothing, rather than an empty list
1347 /// that would render as "the plan is finished".
1348 #[test]
1349 fn a_transcript_with_no_plan_restores_nothing() {
1350 assert!(TodoTool::from_transcript(&[Message::user("hello")]).is_none());
1351 assert!(TodoTool::from_transcript(&[]).is_none());
1352 }
1353
1354 /// `/clear` ends a conversation, and the plan is conversation state. With
1355 /// the list keyed by workspace and a cleared conversation keeping the same
1356 /// jail, a surviving list would be spliced into the *next* conversation's
1357 /// compaction by `carried_state` — the exact failure the keying was for,
1358 /// through the one door keying does not close.
1359 #[tokio::test]
1360 async fn clearing_a_conversation_drops_its_plan() {
1361 let tool = TodoTool::new();
1362 let ctx = ToolCtx::default();
1363 tool.call(
1364 json!({"items": [{"content": "old business", "status": "in_progress"}]}),
1365 &ctx,
1366 )
1367 .await
1368 .unwrap();
1369 assert_eq!(tool.items_in(&ctx.workspace).len(), 1);
1370
1371 tool.forget_conversation_state();
1372 assert!(tool.items_in(&ctx.workspace).is_empty(), "the plan is gone");
1373 assert!(
1374 tool.carried_state(&ctx).is_none(),
1375 "and cannot reach the next conversation's compaction"
1376 );
1377 }
1378
1379 /// A compaction carries the *compacting run's* plan, not whichever list
1380 /// was written most recently by anyone.
1381 #[tokio::test]
1382 async fn carried_state_belongs_to_the_run_being_compacted() {
1383 let tool = TodoTool::new();
1384 let (a, b) = (ctx_in("/w/a"), ctx_in("/w/b"));
1385
1386 tool.call(
1387 json!({"items": [{"content": "ship a", "status": "in_progress"}]}),
1388 &a,
1389 )
1390 .await
1391 .unwrap();
1392 tool.call(
1393 json!({"items": [{"content": "ship b", "status": "in_progress"}]}),
1394 &b,
1395 )
1396 .await
1397 .unwrap();
1398
1399 let carried = tool.carried_state(&a).expect("a has a list to carry");
1400 assert!(carried.body.contains("ship a"));
1401 assert!(
1402 !carried.body.contains("ship b"),
1403 "a compaction must not carry another conversation's plan"
1404 );
1405
1406 // A run that never wrote a list carries nothing, rather than
1407 // inheriting a neighbour's.
1408 assert!(tool.carried_state(&ctx_in("/w/c")).is_none());
1409 }
1410
1411 #[tokio::test]
1412 async fn multiple_in_progress_items_get_a_nudge() {
1413 let tool = TodoTool::new();
1414 let out = tool
1415 .call(
1416 json!({"items": [
1417 {"content": "a", "status": "in_progress"},
1418 {"content": "b", "status": "in_progress"}
1419 ]}),
1420 &ToolCtx::default(),
1421 )
1422 .await
1423 .unwrap();
1424 assert!(!out.is_error, "the write still lands");
1425 assert!(out.content.contains("finish one before starting another"));
1426 }
1427
1428 // --- step appraisal (`docs/GOAL-SYSTEM-DESIGN.md` §5.5) ---
1429 //
1430 // The pure arithmetic is tested in `step.rs`; what these cover is the
1431 // wiring, which is where the false positives live — a reading that fires
1432 // on ordinary work is a line the model learns to skip.
1433
1434 use crate::step::{Outcome, Work};
1435
1436 /// The counters as the loop would stamp them: `calls` is everything in the
1437 /// run's trace, this tool's own writes included.
1438 fn work_ctx(run: u64, calls: u32, last: Option<Outcome>) -> ToolCtx {
1439 ToolCtx {
1440 work: Some(
1441 Work {
1442 calls,
1443 last,
1444 ..Work::default()
1445 }
1446 .in_run(run),
1447 ),
1448 ..ToolCtx::default()
1449 }
1450 }
1451
1452 /// Same, with `in_flight` siblings — the batched shape, where this same
1453 /// `Work` snapshot is handed to every approved call in the turn before
1454 /// any of them (this one included) has landed.
1455 fn batched_work_ctx(run: u64, calls: u32, last: Option<Outcome>, in_flight: u32) -> ToolCtx {
1456 ToolCtx {
1457 work: Some(
1458 Work {
1459 calls,
1460 last,
1461 in_flight,
1462 ..Work::default()
1463 }
1464 .in_run(run),
1465 ),
1466 ..ToolCtx::default()
1467 }
1468 }
1469
1470 async fn write(tool: &TodoTool, ctx: &ToolCtx, items: Value) -> String {
1471 tool.call(json!({ "items": items }), ctx)
1472 .await
1473 .unwrap()
1474 .content
1475 }
1476
1477 #[tokio::test]
1478 async fn a_step_with_work_behind_it_is_appraised_silently() {
1479 let tool = TodoTool::new();
1480 write(
1481 &tool,
1482 &work_ctx(1, 0, None),
1483 json!([{"content": "fix the port", "status": "in_progress"}]),
1484 )
1485 .await;
1486 // Two calls of real work, plus the write above, now in the trace.
1487 let out = write(
1488 &tool,
1489 &work_ctx(1, 3, Some(Outcome::Ok)),
1490 json!([{"content": "fix the port", "status": "completed"}]),
1491 )
1492 .await;
1493 assert!(
1494 !out.contains("fix the port\""),
1495 "the common path says nothing: {out}"
1496 );
1497 }
1498
1499 #[tokio::test]
1500 async fn a_step_marked_done_with_nothing_behind_it_says_so() {
1501 let tool = TodoTool::new();
1502 write(
1503 &tool,
1504 &work_ctx(1, 0, None),
1505 json!([{"content": "fix the port", "status": "in_progress"}]),
1506 )
1507 .await;
1508 // The only call since is the write above.
1509 let out = write(
1510 &tool,
1511 &work_ctx(1, 1, Some(Outcome::Ok)),
1512 json!([{"content": "fix the port", "status": "completed"}]),
1513 )
1514 .await;
1515 assert!(out.contains("no tool calls behind it"), "{out}");
1516 // The list itself is still the first thing the model reads.
1517 assert!(out.starts_with("1/1 done"));
1518 }
1519
1520 /// The null step masked by the bookkeeping that announced it: three plan
1521 /// writes are three trace entries, and counting them as work is how a step
1522 /// where nothing happened reads as busy.
1523 #[tokio::test]
1524 async fn revising_the_plan_is_not_work() {
1525 let tool = TodoTool::new();
1526 let started = json!([{"content": "fix the port", "status": "in_progress"}]);
1527 write(&tool, &work_ctx(1, 0, None), started.clone()).await;
1528 write(&tool, &work_ctx(1, 1, Some(Outcome::Ok)), started).await;
1529 let out = write(
1530 &tool,
1531 &work_ctx(1, 2, Some(Outcome::Ok)),
1532 json!([{"content": "fix the port", "status": "completed"}]),
1533 )
1534 .await;
1535 assert!(out.contains("no tool calls behind it"), "{out}");
1536 }
1537
1538 #[tokio::test]
1539 async fn a_step_never_seen_in_progress_is_not_appraised() {
1540 let tool = TodoTool::new();
1541 // Straight to done in one write: there is no span, and inventing a
1542 // start would measure the whole run against one item.
1543 let out = write(
1544 &tool,
1545 &work_ctx(1, 4, Some(Outcome::Ok)),
1546 json!([{"content": "fix the port", "status": "completed"}]),
1547 )
1548 .await;
1549 assert!(!out.contains("no tool calls"), "{out}");
1550 }
1551
1552 #[tokio::test]
1553 async fn an_unstamped_context_makes_no_claim() {
1554 let tool = TodoTool::new();
1555 let bare = ToolCtx::default();
1556 write(
1557 &tool,
1558 &bare,
1559 json!([{"content": "fix the port", "status": "in_progress"}]),
1560 )
1561 .await;
1562 let out = write(
1563 &tool,
1564 &bare,
1565 json!([{"content": "fix the port", "status": "completed"}]),
1566 )
1567 .await;
1568 assert!(
1569 !out.contains("no tool calls"),
1570 "nobody measured, so nothing is claimed: {out}"
1571 );
1572 }
1573
1574 /// The chat shape. A step started before the user last spoke has a mark in
1575 /// the previous run's units, and differencing across that would announce
1576 /// the null step on ordinary work.
1577 #[tokio::test]
1578 async fn a_step_spanning_two_runs_is_unmeasurable_rather_than_empty() {
1579 let tool = TodoTool::new();
1580 write(
1581 &tool,
1582 &work_ctx(1, 6, Some(Outcome::Ok)),
1583 json!([{"content": "fix the port", "status": "in_progress"}]),
1584 )
1585 .await;
1586 let out = write(
1587 &tool,
1588 &work_ctx(2, 1, Some(Outcome::Ok)),
1589 json!([{"content": "fix the port", "status": "completed"}]),
1590 )
1591 .await;
1592 assert!(!out.contains("no tool calls"), "{out}");
1593 }
1594
1595 #[tokio::test]
1596 async fn a_second_bad_reading_on_one_step_stops_asking_for_a_revision() {
1597 let tool = TodoTool::new();
1598 let started = json!([{"content": "fix the port", "status": "in_progress"}]);
1599 let done = json!([{"content": "fix the port", "status": "completed"}]);
1600
1601 write(&tool, &work_ctx(1, 0, None), started.clone()).await;
1602 let first = write(&tool, &work_ctx(1, 1, Some(Outcome::Ok)), done.clone()).await;
1603 assert!(first.contains("no tool calls behind it") && !first.contains("second time"));
1604
1605 // Put back and ticked again with nothing behind it either time.
1606 write(&tool, &work_ctx(1, 2, Some(Outcome::Ok)), started).await;
1607 let second = write(&tool, &work_ctx(1, 3, Some(Outcome::Ok)), done).await;
1608 assert!(second.contains("second time"), "{second}");
1609 }
1610
1611 #[tokio::test]
1612 async fn a_refused_step_is_reported_as_blocked_and_not_as_broken() {
1613 let tool = TodoTool::new();
1614 write(
1615 &tool,
1616 &work_ctx(1, 0, None),
1617 json!([{"content": "publish the site", "status": "in_progress"}]),
1618 )
1619 .await;
1620 let out = write(
1621 &tool,
1622 &work_ctx(1, 3, Some(Outcome::Refused)),
1623 json!([{"content": "publish the site", "status": "completed"}]),
1624 )
1625 .await;
1626 assert!(out.contains("refused"), "{out}");
1627 assert!(
1628 !out.contains("still failing"),
1629 "the approver doing its job is not the step going wrong: {out}"
1630 );
1631 }
1632
1633 /// A successful revision landing last must not mask an earlier failure:
1634 /// start, a real call fails, the plan is revised (this tool's own write,
1635 /// `Ok`), then completed. The raw trace's tail is the revision, not the
1636 /// failure — only `Tracked::observe`'s own account gets it right.
1637 #[tokio::test]
1638 async fn a_bookkeeping_revision_does_not_mask_an_earlier_failure() {
1639 let tool = TodoTool::new();
1640 let step = json!([{"content": "ship the release", "status": "in_progress"}]);
1641 write(&tool, &work_ctx(1, 0, None), step.clone()).await;
1642 // The real call fails (calls: start's own entry, plus this one).
1643 // The plan tool revises next — a no-op rewrite of the same status —
1644 // and its own write lands as calls=3, `Ok`.
1645 write(&tool, &work_ctx(1, 2, Some(Outcome::Failed)), step).await;
1646 let out = write(
1647 &tool,
1648 &work_ctx(1, 3, Some(Outcome::Ok)),
1649 json!([{"content": "ship the release", "status": "completed"}]),
1650 )
1651 .await;
1652 assert!(
1653 out.contains("still failing"),
1654 "the revision's own `Ok` must not bury the real failure: {out}"
1655 );
1656 }
1657
1658 /// A rejected plan write is still this tool touching its own state, not
1659 /// work on the step — it must not read as the step's own failure just
1660 /// because it is the most recent trace entry when the step completes.
1661 #[tokio::test]
1662 async fn a_rejected_write_does_not_manufacture_a_step_failure() {
1663 let tool = TodoTool::new();
1664 let step = json!([{"content": "ship the release", "status": "in_progress"}]);
1665 write(&tool, &work_ctx(1, 0, None), step).await;
1666 // A real, non-todo call succeeds in between (start's own entry, plus
1667 // this one — no write of ours for it, so `calls` jumps to 2 without
1668 // another call through this tool).
1669 //
1670 // A malformed write this tool rejects comes next — it still lands in
1671 // the trace as a failed call, becoming calls=3 once it returns.
1672 tool.call(
1673 json!({"items": [{"content": "ship the release", "status": "not_a_status"}]}),
1674 &work_ctx(1, 2, Some(Outcome::Ok)),
1675 )
1676 .await
1677 .unwrap();
1678 let out = write(
1679 &tool,
1680 &work_ctx(1, 3, Some(Outcome::Failed)),
1681 json!([{"content": "ship the release", "status": "completed"}]),
1682 )
1683 .await;
1684 assert!(
1685 !out.contains("still failing"),
1686 "a rejected bookkeeping write is not the step's own failure: {out}"
1687 );
1688 assert!(
1689 !out.contains("no tool calls behind it"),
1690 "the real call succeeded, so the span is not empty either: {out}"
1691 );
1692 }
1693
1694 /// The same manufactured-failure shape, with the rejected write batched
1695 /// beside a sibling instead of alone — the shape `in_flight` exists for,
1696 /// and the one `own_calls`'s scalar count cannot tell apart from an
1697 /// unrelated turn unless `next_own_position` accounts for the whole
1698 /// batch landing, not just this tool's own entry.
1699 #[tokio::test]
1700 async fn a_rejected_write_batched_with_a_sibling_does_not_manufacture_a_failure() {
1701 let tool = TodoTool::new();
1702 let step = json!([{"content": "ship the release", "status": "in_progress"}]);
1703 write(&tool, &work_ctx(1, 0, None), step).await;
1704 // A batch of two: a real call that succeeds, and a malformed write
1705 // this tool rejects. `in_flight = 1` (two approved calls this turn).
1706 tool.call(
1707 json!({"items": [{"content": "ship the release", "status": "not_a_status"}]}),
1708 &batched_work_ctx(1, 1, Some(Outcome::Ok), 1),
1709 )
1710 .await
1711 .unwrap();
1712 // Both landed: the start (1), the real call (1), the rejected write
1713 // (1) — calls = 3.
1714 let out = write(
1715 &tool,
1716 &work_ctx(1, 3, Some(Outcome::Failed)),
1717 json!([{"content": "ship the release", "status": "completed"}]),
1718 )
1719 .await;
1720 assert!(
1721 !out.contains("still failing"),
1722 "a rejected bookkeeping write batched with a sibling is not the \
1723 step's own failure: {out}"
1724 );
1725 }
1726
1727 // --- the escalation slot (§5.5's model half) ---
1728
1729 fn escalation_ctx(
1730 run: u64,
1731 calls: u32,
1732 last: Option<Outcome>,
1733 verify_like: u32,
1734 ) -> (
1735 ToolCtx,
1736 std::sync::Arc<std::sync::Mutex<Option<crate::step::StepEscalation>>>,
1737 ) {
1738 let slot = std::sync::Arc::new(std::sync::Mutex::new(None));
1739 let ctx = ToolCtx {
1740 work: Some(
1741 Work {
1742 calls,
1743 last,
1744 verify_like,
1745 // Every test using this helper models a span made of
1746 // `shell` calls — the ordinary case the UnverifiedClaim
1747 // trigger is for — so `shell_calls` tracks `calls` here,
1748 // same convention as `step.rs`'s own `span()` test
1749 // helper.
1750 shell_calls: calls,
1751 ..Work::default()
1752 }
1753 .in_run(run),
1754 ),
1755 step_escalation: Some(slot.clone()),
1756 ..ToolCtx::default()
1757 };
1758 (ctx, slot)
1759 }
1760
1761 /// A run whose escalation slot is `None` — the feature off — behaves
1762 /// exactly as every test above it already proves: no write, no panic.
1763 /// This pins the same property directly against a span large enough that
1764 /// it would be a candidate if the slot existed.
1765 #[tokio::test]
1766 async fn a_span_outlier_with_no_escalation_slot_writes_nothing_and_does_not_panic() {
1767 let tool = TodoTool::new();
1768 for i in 0..2 {
1769 let step = format!("small step {i}");
1770 write(
1771 &tool,
1772 &work_ctx(1, i * 3, None),
1773 json!([{"content": step, "status": "in_progress"}]),
1774 )
1775 .await;
1776 write(
1777 &tool,
1778 &work_ctx(1, i * 3 + 2, Some(Outcome::Ok)),
1779 json!([{"content": step, "status": "completed"}]),
1780 )
1781 .await;
1782 }
1783 write(
1784 &tool,
1785 &work_ctx(1, 6, None),
1786 json!([{"content": "a huge step", "status": "in_progress"}]),
1787 )
1788 .await;
1789 // No slot on this ctx — the feature is off for this run.
1790 write(
1791 &tool,
1792 &work_ctx(1, 30, Some(Outcome::Ok)),
1793 json!([{"content": "a huge step", "status": "completed"}]),
1794 )
1795 .await;
1796 }
1797
1798 #[tokio::test]
1799 async fn a_span_outlier_writes_a_candidate_into_the_slot_when_present() {
1800 let tool = TodoTool::new();
1801 // The tool's own contract ("pass the COMPLETE list every time") means
1802 // a real write carries every step touched so far, finished ones
1803 // included — never just the one currently changing. `completed`'s
1804 // own sweep (`advance`, beside `started`/`flagged`) now prunes
1805 // against exactly that list, so a test that sent one-item plans
1806 // per call would prune its own history before this trigger could
1807 // ever see two siblings.
1808 let mut items: Vec<Value> = Vec::new();
1809 // Two small completed steps establish a baseline mean of ~2.5.
1810 for (i, n) in [2u32, 3u32].into_iter().enumerate() {
1811 let step = format!("small step {i}");
1812 items.push(json!({"content": step, "status": "in_progress"}));
1813 let (start_ctx, _) = escalation_ctx(1, 0, None, 0);
1814 write(&tool, &start_ctx, Value::Array(items.clone())).await;
1815 items.last_mut().unwrap()["status"] = json!("completed");
1816 let (done_ctx, _) = escalation_ctx(1, n, Some(Outcome::Ok), 0);
1817 write(&tool, &done_ctx, Value::Array(items.clone())).await;
1818 }
1819 items.push(json!({"content": "a huge step", "status": "in_progress"}));
1820 let (start_ctx, _) = escalation_ctx(1, 5, None, 0);
1821 write(&tool, &start_ctx, Value::Array(items.clone())).await;
1822 items.last_mut().unwrap()["status"] = json!("completed");
1823 let (done_ctx, slot) = escalation_ctx(1, 25, Some(Outcome::Ok), 0);
1824 write(&tool, &done_ctx, Value::Array(items.clone())).await;
1825 let escalation = slot
1826 .lock()
1827 .unwrap()
1828 .clone()
1829 .expect("20 calls against a mean of 2.5 should have written a candidate");
1830 assert_eq!(
1831 escalation.reason,
1832 crate::step::EscalationReason::SpanOutlier
1833 );
1834 assert_eq!(escalation.step, "a huge step");
1835 }
1836
1837 /// The review finding: `completed` must not survive a step falling out
1838 /// of the live plan, or a wholesale plan rewrite (or a second
1839 /// conversation reusing the same workspace-keyed list) leaves stale
1840 /// history behind as the mean new steps get judged against.
1841 #[tokio::test]
1842 async fn completed_history_is_pruned_once_a_step_leaves_the_live_plan() {
1843 let tool = TodoTool::new();
1844 let mut items: Vec<Value> = Vec::new();
1845 for (i, n) in [2u32, 3u32].into_iter().enumerate() {
1846 let step = format!("small step {i}");
1847 items.push(json!({"content": step, "status": "in_progress"}));
1848 let (start_ctx, _) = escalation_ctx(1, 0, None, 0);
1849 write(&tool, &start_ctx, Value::Array(items.clone())).await;
1850 items.last_mut().unwrap()["status"] = json!("completed");
1851 let (done_ctx, _) = escalation_ctx(1, n, Some(Outcome::Ok), 0);
1852 write(&tool, &done_ctx, Value::Array(items.clone())).await;
1853 }
1854 // A wholesale rewrite: neither of the two small steps rides along.
1855 let (start_ctx, _) = escalation_ctx(1, 5, None, 0);
1856 write(
1857 &tool,
1858 &start_ctx,
1859 json!([{"content": "a huge step", "status": "in_progress"}]),
1860 )
1861 .await;
1862 let (done_ctx, slot) = escalation_ctx(1, 25, Some(Outcome::Ok), 0);
1863 write(
1864 &tool,
1865 &done_ctx,
1866 json!([{"content": "a huge step", "status": "completed"}]),
1867 )
1868 .await;
1869 assert!(
1870 slot.lock().unwrap().is_none(),
1871 "a rewritten plan must not escalate against a mean from steps it no longer holds"
1872 );
1873 }
1874
1875 /// The review finding one step further than the test above: that one
1876 /// puts the rewrite (starting "a huge step" with no small steps in the
1877 /// array) and the *completion* in two separate writes, so the first
1878 /// write's own sweep already prunes `self.completed` before the second
1879 /// write's snapshot is even taken — the bug never gets a chance to
1880 /// appear. Here the rewrite and the completion land in the *same*
1881 /// write: "huge step" is started with the small steps still present
1882 /// (an ordinary write, not a rewrite), then completed in a write whose
1883 /// item array holds only itself. Without filtering the snapshot by
1884 /// `live`, that completion would still see the stale two-step mean the
1885 /// sweep has not pruned yet.
1886 #[tokio::test]
1887 async fn a_rewrite_and_a_completion_in_the_same_write_still_prunes_the_baseline() {
1888 let tool = TodoTool::new();
1889 let mut items: Vec<Value> = Vec::new();
1890 for (i, n) in [2u32, 3u32].into_iter().enumerate() {
1891 let step = format!("small step {i}");
1892 items.push(json!({"content": step, "status": "in_progress"}));
1893 let (start_ctx, _) = escalation_ctx(1, 0, None, 0);
1894 write(&tool, &start_ctx, Value::Array(items.clone())).await;
1895 items.last_mut().unwrap()["status"] = json!("completed");
1896 let (done_ctx, _) = escalation_ctx(1, n, Some(Outcome::Ok), 0);
1897 write(&tool, &done_ctx, Value::Array(items.clone())).await;
1898 }
1899 // An ordinary start — the small steps ride along, so this write is
1900 // not itself a rewrite and its own sweep prunes nothing.
1901 items.push(json!({"content": "huge step", "status": "in_progress"}));
1902 let (start_ctx, _) = escalation_ctx(1, 5, None, 0);
1903 write(&tool, &start_ctx, Value::Array(items.clone())).await;
1904 // The rewrite and the completion together: this write's item array
1905 // holds only "huge step".
1906 let (done_ctx, slot) = escalation_ctx(1, 40, Some(Outcome::Ok), 0);
1907 write(
1908 &tool,
1909 &done_ctx,
1910 json!([{"content": "huge step", "status": "completed"}]),
1911 )
1912 .await;
1913 assert!(
1914 slot.lock().unwrap().is_none(),
1915 "the same write that drops the small steps from the plan must not let \
1916 huge step's own completion see them as its baseline"
1917 );
1918 }
1919
1920 /// The review finding: `completed` is a `Vec` keyed by nothing, unlike
1921 /// `started` (a `HashMap`, so a revision's fresh mark already replaces
1922 /// rather than doubles up). A step completed, reopened, and completed
1923 /// again used to land in `completed` twice under the same name —
1924 /// inflating `sibling_count`/the mean it feeds, and making the step its
1925 /// own sibling in the prompt's list. Here "A" completes small, gets
1926 /// reopened, and completes again large; a later "huge step" must see
1927 /// exactly one "A" entry (its latest span), not two.
1928 #[tokio::test]
1929 async fn a_step_revised_and_recompleted_contributes_one_entry_not_two() {
1930 let tool = TodoTool::new();
1931 let mut items: Vec<Value> = Vec::new();
1932
1933 items.push(json!({"content": "small step 0", "status": "in_progress"}));
1934 write(
1935 &tool,
1936 &escalation_ctx(1, 0, None, 0).0,
1937 Value::Array(items.clone()),
1938 )
1939 .await;
1940 items.last_mut().unwrap()["status"] = json!("completed");
1941 write(
1942 &tool,
1943 &escalation_ctx(1, 2, Some(Outcome::Ok), 0).0,
1944 Value::Array(items.clone()),
1945 )
1946 .await;
1947
1948 items.push(json!({"content": "A", "status": "in_progress"}));
1949 write(
1950 &tool,
1951 &escalation_ctx(1, 2, None, 0).0,
1952 Value::Array(items.clone()),
1953 )
1954 .await;
1955 items.last_mut().unwrap()["status"] = json!("completed");
1956 write(
1957 &tool,
1958 &escalation_ctx(1, 5, Some(Outcome::Ok), 0).0,
1959 Value::Array(items.clone()),
1960 )
1961 .await;
1962
1963 // Reopen A and complete it again, at a much larger span.
1964 items.last_mut().unwrap()["status"] = json!("in_progress");
1965 write(
1966 &tool,
1967 &escalation_ctx(1, 5, None, 0).0,
1968 Value::Array(items.clone()),
1969 )
1970 .await;
1971 items.last_mut().unwrap()["status"] = json!("completed");
1972 write(
1973 &tool,
1974 &escalation_ctx(1, 35, Some(Outcome::Ok), 0).0,
1975 Value::Array(items.clone()),
1976 )
1977 .await;
1978
1979 items.push(json!({"content": "huge step", "status": "in_progress"}));
1980 write(
1981 &tool,
1982 &escalation_ctx(1, 35, None, 0).0,
1983 Value::Array(items.clone()),
1984 )
1985 .await;
1986 items.last_mut().unwrap()["status"] = json!("completed");
1987 let (done_ctx, slot) = escalation_ctx(1, 100, Some(Outcome::Ok), 0);
1988 write(&tool, &done_ctx, Value::Array(items.clone())).await;
1989
1990 let escalation = slot
1991 .lock()
1992 .unwrap()
1993 .clone()
1994 .expect("huge step's span is a clear outlier");
1995 assert_eq!(
1996 escalation.sibling_count, 2,
1997 "A's revision must count once, not twice, among the siblings"
1998 );
1999 assert_eq!(
2000 escalation.siblings.iter().filter(|s| *s == "A").count(),
2001 1,
2002 "A must not be listed as its own sibling twice"
2003 );
2004 }
2005
2006 /// The review finding one step past the test above: that one checks a
2007 /// *later* step's escalation, once the dedupe/push has already run for
2008 /// "A". This checks "A"'s own re-completion — the moment where its
2009 /// pre-revision entry is still sitting in `completed_before_this_batch`
2010 /// (the dedupe/push that would remove it runs *after* the escalation
2011 /// check, and the batch-level `live` filter does not exclude it either,
2012 /// since "A" is in `next.items` — it is the very item being completed).
2013 /// With only "small step 0" as a genuine sibling, this must not clear
2014 /// `SPAN_OUTLIER_MIN_SIBLINGS`.
2015 #[tokio::test]
2016 async fn a_steps_own_pre_revision_entry_does_not_count_as_its_sibling() {
2017 let tool = TodoTool::new();
2018 let mut items: Vec<Value> = Vec::new();
2019
2020 items.push(json!({"content": "small step 0", "status": "in_progress"}));
2021 write(
2022 &tool,
2023 &escalation_ctx(1, 0, None, 0).0,
2024 Value::Array(items.clone()),
2025 )
2026 .await;
2027 items.last_mut().unwrap()["status"] = json!("completed");
2028 write(
2029 &tool,
2030 &escalation_ctx(1, 2, Some(Outcome::Ok), 0).0,
2031 Value::Array(items.clone()),
2032 )
2033 .await;
2034
2035 items.push(json!({"content": "A", "status": "in_progress"}));
2036 write(
2037 &tool,
2038 &escalation_ctx(1, 2, None, 0).0,
2039 Value::Array(items.clone()),
2040 )
2041 .await;
2042 items.last_mut().unwrap()["status"] = json!("completed");
2043 write(
2044 &tool,
2045 &escalation_ctx(1, 4, Some(Outcome::Ok), 0).0,
2046 Value::Array(items.clone()),
2047 )
2048 .await;
2049
2050 // Reopen A and complete it again, at a span that would clear the
2051 // outlier floor against a mean of 2.0 (small step 0 and A's own
2052 // stale entry) but not against the true single-sibling baseline.
2053 items.last_mut().unwrap()["status"] = json!("in_progress");
2054 write(
2055 &tool,
2056 &escalation_ctx(1, 4, None, 0).0,
2057 Value::Array(items.clone()),
2058 )
2059 .await;
2060 items.last_mut().unwrap()["status"] = json!("completed");
2061 let (done_ctx, slot) = escalation_ctx(1, 19, Some(Outcome::Ok), 0);
2062 write(&tool, &done_ctx, Value::Array(items.clone())).await;
2063
2064 assert!(
2065 slot.lock().unwrap().is_none(),
2066 "A's own pre-revision entry must not count as one of its siblings, \
2067 leaving only one real sibling — below SPAN_OUTLIER_MIN_SIBLINGS"
2068 );
2069 }
2070
2071 /// The bug an adversarial review found after the round-2 pruning fix
2072 /// shipped: `advance` loops over every item in one write, and pushes
2073 /// each landed one onto `self.completed` as it goes — so a step earlier
2074 /// in the *same* write's array was, before this fix, already counted in
2075 /// a later step's own mean. Here "medium step" (span 4, below the
2076 /// outlier floor on its own) lands *before* "huge step" in the same
2077 /// array; without the snapshot, huge's comparison would see a 3-step,
2078 /// contaminated baseline instead of the real 2-step one established
2079 /// before this write ever started.
2080 #[tokio::test]
2081 async fn a_step_landing_earlier_in_the_same_write_does_not_contaminate_a_laters_mean() {
2082 let tool = TodoTool::new();
2083 let mut items: Vec<Value> = Vec::new();
2084
2085 // Baseline: two small completed steps, span 2 and 3 (mean 2.5, n=2).
2086 items.push(json!({"content": "small step 0", "status": "in_progress"}));
2087 write(
2088 &tool,
2089 &escalation_ctx(1, 0, None, 0).0,
2090 Value::Array(items.clone()),
2091 )
2092 .await;
2093 items.last_mut().unwrap()["status"] = json!("completed");
2094 write(
2095 &tool,
2096 &escalation_ctx(1, 3, Some(Outcome::Ok), 0).0,
2097 Value::Array(items.clone()),
2098 )
2099 .await;
2100
2101 items.push(json!({"content": "small step 1", "status": "in_progress"}));
2102 write(
2103 &tool,
2104 &escalation_ctx(1, 3, None, 0).0,
2105 Value::Array(items.clone()),
2106 )
2107 .await;
2108 items.last_mut().unwrap()["status"] = json!("completed");
2109 write(
2110 &tool,
2111 &escalation_ctx(1, 7, Some(Outcome::Ok), 0).0,
2112 Value::Array(items.clone()),
2113 )
2114 .await;
2115
2116 // "huge step" starts first (span will end up large); "medium step"
2117 // starts later (span will end up small) — both finish in one write.
2118 items.push(json!({"content": "huge step", "status": "in_progress"}));
2119 write(
2120 &tool,
2121 &escalation_ctx(1, 7, None, 0).0,
2122 Value::Array(items.clone()),
2123 )
2124 .await;
2125 items.push(json!({"content": "medium step", "status": "in_progress"}));
2126 write(
2127 &tool,
2128 &escalation_ctx(1, 37, None, 0).0,
2129 Value::Array(items.clone()),
2130 )
2131 .await;
2132
2133 // "medium step" precedes "huge step" in the array — the exact
2134 // ordering the bug needed to reach "huge"'s comparison at all.
2135 let last = items.len() - 1;
2136 items[last - 1]["status"] = json!("completed"); // medium step
2137 items[last]["status"] = json!("completed"); // huge step
2138 items.swap(last - 1, last); // medium now BEFORE huge in the array
2139 let (done_ctx, slot) = escalation_ctx(1, 42, Some(Outcome::Ok), 0);
2140 write(&tool, &done_ctx, Value::Array(items.clone())).await;
2141
2142 let escalation = slot
2143 .lock()
2144 .unwrap()
2145 .clone()
2146 .expect("huge step's span (33) is a clear outlier against the real baseline");
2147 assert_eq!(escalation.step, "huge step");
2148 assert_eq!(
2149 escalation.sibling_count, 2,
2150 "medium step landed earlier in this same write and must not count as a third sibling"
2151 );
2152 assert_eq!(escalation.sibling_mean_calls, Some(2.5));
2153 }
2154
2155 /// Two genuine candidates in one write — the slot holds exactly one, and
2156 /// it is the first one `advance` reaches, not whichever happened to be
2157 /// processed last. Silently overwriting an earlier candidate with a
2158 /// later one would make survival an accident of iteration order.
2159 #[tokio::test]
2160 async fn two_outliers_in_one_write_keep_only_the_first_found() {
2161 let tool = TodoTool::new();
2162 let mut items: Vec<Value> = Vec::new();
2163
2164 items.push(json!({"content": "small step 0", "status": "in_progress"}));
2165 write(
2166 &tool,
2167 &escalation_ctx(1, 0, None, 0).0,
2168 Value::Array(items.clone()),
2169 )
2170 .await;
2171 items.last_mut().unwrap()["status"] = json!("completed");
2172 write(
2173 &tool,
2174 &escalation_ctx(1, 3, Some(Outcome::Ok), 0).0,
2175 Value::Array(items.clone()),
2176 )
2177 .await;
2178
2179 items.push(json!({"content": "small step 1", "status": "in_progress"}));
2180 write(
2181 &tool,
2182 &escalation_ctx(1, 3, None, 0).0,
2183 Value::Array(items.clone()),
2184 )
2185 .await;
2186 items.last_mut().unwrap()["status"] = json!("completed");
2187 write(
2188 &tool,
2189 &escalation_ctx(1, 7, Some(Outcome::Ok), 0).0,
2190 Value::Array(items.clone()),
2191 )
2192 .await;
2193
2194 items.push(json!({"content": "big step A", "status": "in_progress"}));
2195 write(
2196 &tool,
2197 &escalation_ctx(1, 7, None, 0).0,
2198 Value::Array(items.clone()),
2199 )
2200 .await;
2201 items.push(json!({"content": "big step B", "status": "in_progress"}));
2202 write(
2203 &tool,
2204 &escalation_ctx(1, 40, None, 0).0,
2205 Value::Array(items.clone()),
2206 )
2207 .await;
2208
2209 let last = items.len() - 1;
2210 items[last - 1]["status"] = json!("completed"); // big step A, first in the array
2211 items[last]["status"] = json!("completed"); // big step B, second in the array
2212 let (done_ctx, slot) = escalation_ctx(1, 80, Some(Outcome::Ok), 0);
2213 write(&tool, &done_ctx, Value::Array(items.clone())).await;
2214
2215 let escalation = slot
2216 .lock()
2217 .unwrap()
2218 .clone()
2219 .expect("both steps' spans are clear outliers");
2220 assert_eq!(
2221 escalation.step, "big step A",
2222 "the first candidate `advance` reaches must win, deterministically"
2223 );
2224 }
2225
2226 #[tokio::test]
2227 async fn an_unverified_claim_writes_a_candidate_and_a_verified_one_does_not() {
2228 let tool = TodoTool::new();
2229
2230 let (start_ctx, _) = escalation_ctx(1, 0, None, 0);
2231 write(
2232 &tool,
2233 &start_ctx,
2234 json!([{"content": "test that the API responds", "status": "in_progress"}]),
2235 )
2236 .await;
2237 // No verify-shaped call in the span (verify_like stays 0).
2238 let (done_ctx, slot) = escalation_ctx(1, 3, Some(Outcome::Ok), 0);
2239 write(
2240 &tool,
2241 &done_ctx,
2242 json!([{"content": "test that the API responds", "status": "completed"}]),
2243 )
2244 .await;
2245 let escalation = slot
2246 .lock()
2247 .unwrap()
2248 .clone()
2249 .expect("an unverified claim should have written a candidate");
2250 assert_eq!(
2251 escalation.reason,
2252 crate::step::EscalationReason::UnverifiedClaim
2253 );
2254
2255 // Same claim, but this time the span actually contains a
2256 // verify-shaped call — no candidate.
2257 let (start_ctx, _) = escalation_ctx(2, 0, None, 0);
2258 write(
2259 &tool,
2260 &start_ctx,
2261 json!([{"content": "test that the widget renders", "status": "in_progress"}]),
2262 )
2263 .await;
2264 let (done_ctx, slot) = escalation_ctx(2, 3, Some(Outcome::Ok), 1);
2265 write(
2266 &tool,
2267 &done_ctx,
2268 json!([{"content": "test that the widget renders", "status": "completed"}]),
2269 )
2270 .await;
2271 assert!(slot.lock().unwrap().is_none());
2272 }
2273}