Skip to main content

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}