Skip to main content

newt_core/agentic/
scheduled.rs

1//! Scheduled context — the `scheduled` context feature (Step 26.6b, #586).
2//!
3//! A "compiled per-step view": instead of leaning on an ever-growing chat
4//! buffer, the model keeps an ORDERED plan of steps (each Todo / Active / Done)
5//! and a compiled `<plan>` checklist is injected at the head of every turn. The
6//! model maintains it with `update_plan` (send the full ordered list each time,
7//! each step tagged pending/in_progress/completed), so it stays oriented around
8//! its plan's progress rather than re-deriving it from a long transcript.
9//!
10//! Pure in-memory + deterministic (a `Vec`, no clock/uuid), so the whole feature
11//! unit-tests with zero network/fs. Mirrors `scratchpad.rs`: a `&self`
12//! interior-mutability trait + an in-memory impl. The plan is task-specific →
13//! cleared on `/new`.
14//!
15//! Out of scope (a future enhancement): actually RESETTING the rolling window
16//! around the compiled view. v1 injects the `<plan>` view alongside the normal
17//! window rather than replacing it — honest + non-invasive.
18
19use super::display::{print_tool_call, print_tool_output};
20use serde::{Deserialize, Serialize};
21use std::sync::Mutex;
22
23/// A plan step's progress.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum StepStatus {
26    Todo,
27    Active,
28    Done,
29}
30
31/// One step in the plan.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct Step {
34    pub description: String,
35    pub status: StepStatus,
36}
37
38/// A serializable full-state snapshot of the plan ledger (#715). Persisted onto
39/// the conversation row so an interrupt + auto-resume can re-hydrate the
40/// in-memory ledger EXACTLY — the ordered steps AND each step's done/active
41/// status — instead of rebuilding it empty (which loses the `<plan>` block and
42/// `plan_get` after a resume). The active step is encoded by its
43/// [`StepStatus::Active`] marker, so the snapshot carries no separate index to
44/// drift out of sync.
45///
46/// Unlike [`StepLedger::set_plan`] (which takes bare step strings and RESETS
47/// every status — the first step Active, the rest Todo), a snapshot/restore
48/// round-trip preserves which steps are Done and which is Active.
49///
50/// Additive + **working memory, not provenance**: it rides the conversation row
51/// (never a turn), is kept OUT of the §6 content chain, and an empty snapshot is
52/// skipped on serialize so it never bloats the on-disk file.
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54pub struct PlanSnapshot {
55    /// The ordered steps with each one's status. `#[serde(default)]` so the
56    /// historically-true empty backfill (`{}`) on an older db parses cleanly.
57    #[serde(default)]
58    pub steps: Vec<Step>,
59}
60
61impl PlanSnapshot {
62    /// Whether the snapshot holds no steps (the OFF/empty case — used by the
63    /// record's `skip_serializing_if` and the resume banner).
64    #[must_use]
65    pub fn is_empty(&self) -> bool {
66        self.steps.is_empty()
67    }
68    /// Number of steps in the snapshot.
69    #[must_use]
70    pub fn len(&self) -> usize {
71        self.steps.len()
72    }
73}
74
75/// Max steps a plan may hold (a guard; extra steps are dropped on set).
76pub(crate) const MAX_STEPS: usize = 30;
77/// Per-step description cap in the `<plan>` view.
78pub(crate) const STEP_DESC_CAP: usize = 200;
79/// Whole-block char cap so a long plan can never blow the send budget.
80pub(crate) const PLAN_TOTAL_CAP: usize = 3_000;
81
82/// A session store for the ordered plan (Step 26.6b). `&self` methods (interior
83/// mutability) so one shared `&dyn StepLedger` serves both the per-turn `<plan>`
84/// injection and the update_plan tool. Task-specific → cleared on `/new`.
85pub trait StepLedger: Send + Sync {
86    /// Replace the plan with a fresh ordered list — the first step becomes
87    /// Active, the rest Todo. Blank descriptions are dropped; capped at
88    /// [`MAX_STEPS`]. Returns the number of steps actually set.
89    fn set_plan(&self, steps: &[String]) -> usize;
90    /// Mark the Active step Done and activate the next Todo. Returns the new
91    /// active step's description, or `None` when the plan is complete/empty.
92    fn advance(&self) -> Option<String>;
93    /// A snapshot of the steps (for the `<plan>` block).
94    fn steps(&self) -> Vec<Step>;
95    /// Total steps (for `/context stats`).
96    fn count(&self) -> u64;
97    /// Completed steps (for `/context stats`).
98    fn done_count(&self) -> u64;
99    /// Drop the plan (`/new`).
100    fn clear(&self);
101    /// A full-state [`PlanSnapshot`] for persistence/resume (#715): the ordered
102    /// steps with each one's status, so a later [`Self::restore`] reinstates the
103    /// ledger EXACTLY (unlike [`Self::set_plan`], which resets every status).
104    fn snapshot(&self) -> PlanSnapshot;
105    /// Restore the ledger from a [`PlanSnapshot`] (#715) — replaces the steps
106    /// and their statuses verbatim (a full clear + replace, the inverse of
107    /// [`Self::snapshot`]). An empty snapshot leaves the ledger empty.
108    fn restore(&self, snap: &PlanSnapshot);
109}
110
111/// In-memory, session-scoped [`StepLedger`] — pure (no fs). A `Vec` in plan
112/// order; deterministic (no clock/uuid) for stable tests.
113#[derive(Default)]
114pub struct SessionStepLedger {
115    steps: Mutex<Vec<Step>>,
116}
117
118impl StepLedger for SessionStepLedger {
119    fn set_plan(&self, steps: &[String]) -> usize {
120        let mut built: Vec<Step> = steps
121            .iter()
122            .map(|s| s.trim())
123            .filter(|s| !s.is_empty())
124            .take(MAX_STEPS)
125            .map(|s| Step {
126                description: s.to_string(),
127                status: StepStatus::Todo,
128            })
129            .collect();
130        if let Some(first) = built.first_mut() {
131            first.status = StepStatus::Active;
132        }
133        let n = built.len();
134        *self.steps.lock().unwrap() = built;
135        n
136    }
137
138    fn advance(&self) -> Option<String> {
139        let mut steps = self.steps.lock().unwrap();
140        // Finish the current Active step (if any).
141        if let Some(active) = steps.iter_mut().find(|s| s.status == StepStatus::Active) {
142            active.status = StepStatus::Done;
143        }
144        // Activate the next Todo, if one remains.
145        if let Some(next) = steps.iter_mut().find(|s| s.status == StepStatus::Todo) {
146            next.status = StepStatus::Active;
147            Some(next.description.clone())
148        } else {
149            None
150        }
151    }
152
153    fn steps(&self) -> Vec<Step> {
154        self.steps.lock().unwrap().clone()
155    }
156    fn count(&self) -> u64 {
157        self.steps.lock().unwrap().len() as u64
158    }
159    fn done_count(&self) -> u64 {
160        self.steps
161            .lock()
162            .unwrap()
163            .iter()
164            .filter(|s| s.status == StepStatus::Done)
165            .count() as u64
166    }
167    fn clear(&self) {
168        self.steps.lock().unwrap().clear();
169    }
170    fn snapshot(&self) -> PlanSnapshot {
171        PlanSnapshot {
172            steps: self.steps.lock().unwrap().clone(),
173        }
174    }
175    fn restore(&self, snap: &PlanSnapshot) {
176        *self.steps.lock().unwrap() = snap.steps.clone();
177    }
178}
179
180fn truncate(s: &str, cap: usize) -> String {
181    if s.chars().count() <= cap {
182        s.to_string()
183    } else {
184        let head: String = s.chars().take(cap).collect();
185        format!("{head}[…]")
186    }
187}
188
189/// Render the compiled `<plan>` checklist injected at the head of a turn (Step
190/// 26.6b). `None` when the plan is empty — the OFF/empty bit-for-bit guarantee
191/// (mirror `scratchpad::build_state_block`). `✓` done, `→` active, `☐` todo.
192pub(crate) fn build_plan_block(ledger: &dyn StepLedger, total_cap: usize) -> Option<String> {
193    let steps = ledger.steps();
194    if steps.is_empty() {
195        return None;
196    }
197    let mut body = String::from("<plan>\n");
198    for (i, step) in steps.iter().enumerate() {
199        let mark = match step.status {
200            StepStatus::Done => "✓",
201            StepStatus::Active => "→",
202            StepStatus::Todo => "☐",
203        };
204        let piece = format!(
205            "{mark} {}. {}\n",
206            i + 1,
207            truncate(&step.description, STEP_DESC_CAP)
208        );
209        if body.chars().count() + piece.chars().count() + "</plan>".len() > total_cap {
210            body.push_str("[… plan truncated to fit the budget …]\n");
211            break;
212        }
213        body.push_str(&piece);
214    }
215    body.push_str("</plan>");
216    Some(body)
217}
218
219/// TUI-facing entry: the compiled `<plan>` block with the default cap (Step
220/// 26.6b). `None` when the plan is empty.
221pub fn plan_block(ledger: &dyn StepLedger) -> Option<String> {
222    build_plan_block(ledger, PLAN_TOTAL_CAP)
223}
224
225/// A COMPACT per-round re-seat pointer — the active step + progress on one line.
226///
227/// `None` unless a MULTI-STEP plan is in progress (≥2 steps and an Active step
228/// remains). This is the conditional re-seat validated by the dgx1 12-step A/B
229/// (re-seat **12/12** vs baseline **8/12** under drift; see
230/// `docs/research/weak-model-plan-mode-findings.md`): the round-0 `<plan>`
231/// snapshot goes stale across the up-to-N tool rounds, so a weak model "loses
232/// track of its plan over time". Re-showing only the active step each round keeps
233/// it on course at a fraction of the token cost of re-injecting the full `<plan>`
234/// (which mildly *hurt* on no-drift tasks). Gated to multi-step in-progress plans
235/// so trivial/single-step turns pay nothing.
236#[must_use]
237pub fn plan_reseat_pointer(ledger: &dyn StepLedger) -> Option<String> {
238    let steps = ledger.steps();
239    if steps.len() < 2 {
240        return None; // no plan, or a single-step plan that needs no re-seat
241    }
242    let active = steps.iter().position(|s| s.status == StepStatus::Active)?;
243    let done = steps
244        .iter()
245        .filter(|s| s.status == StepStatus::Done)
246        .count();
247    Some(format!(
248        "[Plan progress: {done}/{} done. ACTIVE \u{2192} step {}: {}. Keep working \
249         THIS step; earlier steps are already done — do not restart or re-plan them.]",
250        steps.len(),
251        active + 1,
252        truncate(&steps[active].description, STEP_DESC_CAP)
253    ))
254}
255
256// ---------------------------------------------------------------------------
257// Tool schemas (advertised only when the feature is on + a ledger is present)
258// ---------------------------------------------------------------------------
259
260/// `update_plan` tool definition (#715 PR2) — the single WRITE surface that
261/// replaces `plan_set` + `plan_advance`. The Codex shape: send the full ordered
262/// list each turn, each step tagged with its status (exactly one `in_progress`).
263pub fn update_plan_tool_definition() -> serde_json::Value {
264    serde_json::json!({
265        "type": "function",
266        "function": {
267            "name": "update_plan",
268            "description": "Create or update your plan — send the full ordered list each time \
269                            with each step's status. A <plan> checklist is shown at the head of \
270                            every turn; mark the step you are on \"in_progress\" and finished \
271                            steps \"completed\". Prefer this before more investigation for \
272                            multi-step, ambiguous, resumed, or context-compacted work. Replaces \
273                            plan_set/plan_advance.",
274            "parameters": {
275                "type": "object",
276                "properties": {
277                    "plan": {
278                        "type": "array",
279                        "items": {
280                            "type": "object",
281                            "properties": {
282                                "step": {
283                                    "type": "string",
284                                    "description": "A short imperative phrase for the step."
285                                },
286                                "status": {
287                                    "type": "string",
288                                    "enum": ["pending", "in_progress", "completed"],
289                                    "description": "The step's progress; exactly one step \
290                                                    should be in_progress."
291                                }
292                            },
293                            "required": ["step", "status"]
294                        },
295                        "description": "The ordered steps, each with its status."
296                    }
297                },
298                "required": ["plan"]
299            }
300        }
301    })
302}
303
304/// `plan_get` tool definition (#716) — a read-only read of the current plan.
305pub fn plan_get_tool_definition() -> serde_json::Value {
306    serde_json::json!({
307        "type": "function",
308        "function": {
309            "name": "plan_get",
310            "description": "Read the current <plan> checklist — the ordered steps and which is \
311                            active. No args. Use it to recover what you were working on after a \
312                            resume; if it reports no active plan, create one with update_plan \
313                            instead of calling plan_get again.",
314            "parameters": { "type": "object", "properties": {}, "required": [] }
315        }
316    })
317}
318
319// ---------------------------------------------------------------------------
320// Executors (every branch returns a tool-result String, never a loop abort)
321// ---------------------------------------------------------------------------
322
323/// Map a tool-supplied `status` string onto a [`StepStatus`]. Lenient: the
324/// schema's canonical `pending`/`in_progress`/`completed` plus their obvious
325/// synonyms; anything unrecognized (or missing) falls back to `Todo` so a noisy
326/// status never aborts the call.
327fn parse_step_status(raw: &str) -> StepStatus {
328    match raw.trim().to_ascii_lowercase().as_str() {
329        "completed" | "complete" | "done" => StepStatus::Done,
330        "in_progress" | "in-progress" | "active" | "current" => StepStatus::Active,
331        _ => StepStatus::Todo, // "pending" / "todo" / unknown
332    }
333}
334
335/// Enforce the Codex invariant of EXACTLY ONE active step — leniently. If the
336/// model sent no `in_progress` (or several), the first non-completed step is
337/// made active and any other actives demoted to Todo. An all-completed plan is
338/// left with no active step (the plan is done).
339fn normalize_active(steps: &mut [Step]) {
340    let active = steps
341        .iter()
342        .filter(|s| s.status == StepStatus::Active)
343        .count();
344    if active == 1 {
345        return; // the well-formed case — respect the model's choice
346    }
347    for s in steps.iter_mut() {
348        if s.status == StepStatus::Active {
349            s.status = StepStatus::Todo;
350        }
351    }
352    if let Some(first) = steps.iter_mut().find(|s| s.status != StepStatus::Done) {
353        first.status = StepStatus::Active;
354    }
355}
356
357/// Execute an `update_plan` call (#715 PR2) — the single WRITE surface. Parses
358/// the `{plan:[{step,status}]}` array into a [`PlanSnapshot`] and reinstates the
359/// ledger via [`StepLedger::restore`] (the same full-state path persistence uses,
360/// so statuses land verbatim). Returns the rendered `<plan>` block so the model
361/// sees the result; read it back later with `plan_get`.
362pub(crate) fn execute_update_plan(
363    args: &serde_json::Value,
364    ledger: &dyn StepLedger,
365    color: bool,
366    tool_output_lines: usize,
367) -> String {
368    print_tool_call("update_plan", "", color);
369    let out = match args["plan"].as_array() {
370        None => "error: update_plan requires a `plan` array of {\"step\",\"status\"} objects"
371            .to_string(),
372        Some(items) => {
373            let mut steps: Vec<Step> = items
374                .iter()
375                .filter_map(|it| {
376                    let desc = it.get("step").and_then(|v| v.as_str())?.trim();
377                    if desc.is_empty() {
378                        return None;
379                    }
380                    let status = it
381                        .get("status")
382                        .and_then(|v| v.as_str())
383                        .map_or(StepStatus::Todo, parse_step_status);
384                    Some(Step {
385                        description: desc.to_string(),
386                        status,
387                    })
388                })
389                .take(MAX_STEPS)
390                .collect();
391            if steps.is_empty() {
392                "error: update_plan requires a non-empty `plan` array".to_string()
393            } else {
394                normalize_active(&mut steps);
395                ledger.restore(&PlanSnapshot { steps });
396                plan_block(ledger).unwrap_or_else(|| "plan updated".to_string())
397            }
398        }
399    };
400    print_tool_output(&out, tool_output_lines, color);
401    out
402}
403
404/// Execute a `plan_get` call (#716) — render the current `<plan>` checklist
405/// read-only (no ledger mutation), so a resumed turn can recover "what was I
406/// working on". Empty plan → a hint to start one with `update_plan`.
407pub(crate) fn execute_plan_get(
408    ledger: &dyn StepLedger,
409    color: bool,
410    tool_output_lines: usize,
411) -> String {
412    print_tool_call("plan_get", "", color);
413    let out = plan_block(ledger).unwrap_or_else(|| {
414        "no active plan — if this is multi-step, ambiguous, resumed, or context-compacted work, \
415         call update_plan next with a short 2-6 step ordered plan using statuses \
416         pending/in_progress/completed; do not call plan_get again until you have created or \
417         updated a plan"
418            .to_string()
419    });
420    print_tool_output(&out, tool_output_lines, color);
421    out
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn set_plan_filters_blanks_caps_and_activates_first() {
430        let l = SessionStepLedger::default();
431        let n = l.set_plan(&[
432            "  read the code  ".to_string(),
433            "".to_string(),
434            "  ".to_string(),
435            "write the fix".to_string(),
436        ]);
437        assert_eq!(n, 2, "blank steps dropped");
438        let steps = l.steps();
439        assert_eq!(steps[0].description, "read the code", "trimmed");
440        assert_eq!(steps[0].status, StepStatus::Active, "first is active");
441        assert_eq!(steps[1].status, StepStatus::Todo);
442        // cap at MAX_STEPS
443        let many: Vec<String> = (0..MAX_STEPS + 10).map(|i| format!("step {i}")).collect();
444        assert_eq!(l.set_plan(&many), MAX_STEPS, "capped at MAX_STEPS");
445        // clear empties the plan (/new path)
446        l.clear();
447        assert_eq!(l.count(), 0);
448        assert!(l.steps().is_empty());
449    }
450
451    #[test]
452    fn advance_walks_active_to_done_and_reports_complete() {
453        let l = SessionStepLedger::default();
454        l.set_plan(&["a".to_string(), "b".to_string()]);
455        assert_eq!(l.done_count(), 0);
456        // advance: a → Done, b → Active, returns "b"
457        assert_eq!(l.advance().as_deref(), Some("b"));
458        let steps = l.steps();
459        assert_eq!(steps[0].status, StepStatus::Done);
460        assert_eq!(steps[1].status, StepStatus::Active);
461        assert_eq!(l.done_count(), 1);
462        // advance again: b → Done, no Todo left → None (complete)
463        assert_eq!(l.advance(), None);
464        assert_eq!(l.done_count(), 2);
465        // advancing a complete plan stays None, no panic
466        assert_eq!(l.advance(), None);
467        // advancing an empty plan → None
468        let empty = SessionStepLedger::default();
469        assert_eq!(empty.advance(), None);
470    }
471
472    #[test]
473    fn snapshot_restore_round_trips_full_state_unlike_set_plan() {
474        // #715: a snapshot captures the FULL state (steps + per-step status), so
475        // restore reinstates which steps are Done / Active — the resume-safety
476        // property set_plan lacks (it resets every status).
477        let l = SessionStepLedger::default();
478        l.set_plan(&["a".to_string(), "b".to_string(), "c".to_string()]);
479        l.advance(); // a → Done, b → Active
480        let snap = l.snapshot();
481        assert_eq!(snap.len(), 3);
482        assert!(!snap.is_empty());
483        assert_eq!(snap.steps[0].status, StepStatus::Done);
484        assert_eq!(snap.steps[1].status, StepStatus::Active);
485        assert_eq!(snap.steps[2].status, StepStatus::Todo);
486
487        // It survives a serde round-trip (the persistence path).
488        let json = serde_json::to_string(&snap).unwrap();
489        let decoded: PlanSnapshot = serde_json::from_str(&json).unwrap();
490        assert_eq!(decoded, snap, "snapshot must survive serde verbatim");
491        // The `{}` backfill (an older db / OFF feature) parses to an empty plan.
492        assert_eq!(
493            serde_json::from_str::<PlanSnapshot>("{}").unwrap(),
494            PlanSnapshot::default(),
495            "the `{{}}` backfill parses to an empty snapshot"
496        );
497
498        // Restore into a FRESH ledger reinstates the exact state — not a reset.
499        let fresh = SessionStepLedger::default();
500        fresh.restore(&decoded);
501        assert_eq!(fresh.snapshot(), snap, "restore reinstates verbatim");
502        assert_eq!(fresh.done_count(), 1, "the Done step survives restore");
503        let block = build_plan_block(&fresh, 3000).unwrap();
504        assert!(block.contains("✓ 1. a"), "{block}");
505        assert!(block.contains("→ 2. b"), "{block}");
506        assert!(block.contains("☐ 3. c"), "{block}");
507        // Contrast: set_plan would have reset b/c to Active/Todo and lost a's Done.
508        let reset = SessionStepLedger::default();
509        reset.set_plan(&["a".to_string(), "b".to_string(), "c".to_string()]);
510        assert_eq!(reset.done_count(), 0, "set_plan resets — proving the gap");
511
512        // Restoring an empty snapshot clears (a conversation boundary).
513        fresh.restore(&PlanSnapshot::default());
514        assert_eq!(
515            fresh.count(),
516            0,
517            "an empty snapshot leaves the ledger empty"
518        );
519    }
520
521    #[test]
522    fn reseat_pointer_compact_and_gated_to_multistep_in_progress() {
523        let l = SessionStepLedger::default();
524        assert!(plan_reseat_pointer(&l).is_none(), "empty → none");
525        l.set_plan(&["only".to_string()]);
526        assert!(plan_reseat_pointer(&l).is_none(), "single step → none");
527        l.set_plan(&["a".to_string(), "b".to_string(), "c".to_string()]);
528        let p = plan_reseat_pointer(&l).expect("multi-step → pointer");
529        assert!(p.contains("step 1: a"), "names the active step: {p}");
530        assert!(p.contains("0/3 done"), "shows progress: {p}");
531        assert_eq!(p.lines().count(), 1, "compact (one line): {p}");
532        l.advance(); // a Done, b Active
533        let ptr = plan_reseat_pointer(&l).unwrap();
534        assert!(
535            ptr.contains("step 2: b") && ptr.contains("1/3 done"),
536            "{ptr}"
537        );
538        l.advance();
539        l.advance(); // all done, no Active
540        assert!(plan_reseat_pointer(&l).is_none(), "complete plan → none");
541    }
542
543    #[test]
544    fn build_block_none_when_empty_marks_status_and_caps() {
545        let l = SessionStepLedger::default();
546        assert_eq!(build_plan_block(&l, 3000), None, "empty plan → no block");
547        l.set_plan(&["alpha".to_string(), "beta".to_string(), "gamma".to_string()]);
548        l.advance(); // alpha Done, beta Active
549        let block = build_plan_block(&l, 3000).unwrap();
550        assert!(block.starts_with("<plan>\n") && block.ends_with("</plan>"));
551        assert!(block.contains("✓ 1. alpha"), "{block}");
552        assert!(block.contains("→ 2. beta"), "{block}");
553        assert!(block.contains("☐ 3. gamma"), "{block}");
554        // a single over-long step description is truncated with the […] marker
555        let long = SessionStepLedger::default();
556        long.set_plan(&["x".repeat(STEP_DESC_CAP + 50)]);
557        let lb = build_plan_block(&long, 3000).unwrap();
558        assert!(
559            lb.contains("[…]"),
560            "per-step description truncated: {lb:.80}"
561        );
562        // total cap trips the marker
563        let big = SessionStepLedger::default();
564        let many: Vec<String> = (0..MAX_STEPS)
565            .map(|i| format!("step number {i} with padding"))
566            .collect();
567        big.set_plan(&many);
568        let capped = build_plan_block(&big, 200).unwrap();
569        assert!(
570            capped.chars().count() <= 200 + 60,
571            "total cap bounds the block"
572        );
573        assert!(capped.contains("plan truncated"), "{capped}");
574    }
575
576    #[test]
577    fn update_plan_sets_statuses_renders_block_and_is_lenient() {
578        // #715 PR2: update_plan is the single WRITE surface ({plan:[{step,status}]}).
579        let l = SessionStepLedger::default();
580        // missing `plan` → coaching error, ledger untouched
581        assert!(execute_update_plan(&serde_json::json!({}), &l, false, 20).starts_with("error:"));
582        assert_eq!(l.count(), 0);
583        // empty array → error, still untouched
584        assert!(
585            execute_update_plan(&serde_json::json!({"plan": []}), &l, false, 20)
586                .starts_with("error:")
587        );
588        assert_eq!(l.count(), 0);
589        // a full plan with one in_progress → statuses land in the ledger AND the
590        // rendered <plan> block comes back.
591        let out = execute_update_plan(
592            &serde_json::json!({"plan": [
593                {"step": "scope it", "status": "completed"},
594                {"step": "build it", "status": "in_progress"},
595                {"step": "test it",  "status": "pending"},
596            ]}),
597            &l,
598            false,
599            20,
600        );
601        assert!(
602            out.starts_with("<plan>\n") && out.ends_with("</plan>"),
603            "{out}"
604        );
605        assert!(out.contains("✓ 1. scope it"), "{out}");
606        assert!(out.contains("→ 2. build it"), "{out}");
607        assert!(out.contains("☐ 3. test it"), "{out}");
608        let steps = l.steps();
609        assert_eq!(steps[0].status, StepStatus::Done);
610        assert_eq!(steps[1].status, StepStatus::Active);
611        assert_eq!(steps[2].status, StepStatus::Todo);
612        assert_eq!(
613            steps
614                .iter()
615                .filter(|s| s.status == StepStatus::Active)
616                .count(),
617            1,
618            "exactly one in_progress"
619        );
620        // lenient: NO in_progress → the first non-completed becomes active.
621        let none_active = execute_update_plan(
622            &serde_json::json!({"plan": [
623                {"step": "a", "status": "completed"},
624                {"step": "b", "status": "pending"},
625                {"step": "c", "status": "pending"},
626            ]}),
627            &l,
628            false,
629            20,
630        );
631        assert!(
632            none_active.contains("→ 2. b"),
633            "first non-completed becomes active: {none_active}"
634        );
635        // lenient: MULTIPLE in_progress → only the first non-completed stays active.
636        execute_update_plan(
637            &serde_json::json!({"plan": [
638                {"step": "x", "status": "in_progress"},
639                {"step": "y", "status": "in_progress"},
640            ]}),
641            &l,
642            false,
643            20,
644        );
645        let s = l.steps();
646        assert_eq!(
647            s.iter().filter(|x| x.status == StepStatus::Active).count(),
648            1,
649            "multiple in_progress collapse to one"
650        );
651        assert_eq!(s[0].status, StepStatus::Active, "the first becomes active");
652        // blank step descriptions are dropped (the set_plan filtering parity).
653        let dropped = execute_update_plan(
654            &serde_json::json!({"plan": [
655                {"step": "  ", "status": "pending"},
656                {"step": "real", "status": "in_progress"},
657            ]}),
658            &l,
659            false,
660            20,
661        );
662        assert!(dropped.contains("→ 1. real"), "{dropped}");
663        assert_eq!(l.count(), 1, "blank step dropped");
664    }
665
666    #[test]
667    fn tool_definitions_shape() {
668        assert_eq!(
669            update_plan_tool_definition()["function"]["name"],
670            "update_plan"
671        );
672        assert_eq!(plan_get_tool_definition()["function"]["name"], "plan_get");
673        // update_plan takes the `plan` array of {step,status} objects.
674        assert!(
675            update_plan_tool_definition()["function"]["parameters"]["properties"]["plan"]
676                .is_object()
677        );
678        // #716: plan_get is read-only — no args.
679        assert_eq!(
680            plan_get_tool_definition()["function"]["parameters"]["properties"],
681            serde_json::json!({})
682        );
683    }
684
685    #[test]
686    fn plan_get_renders_plan_or_hints_when_empty() {
687        // #716: empty ledger → a hint to start a plan (no mutation).
688        let l = SessionStepLedger::default();
689        let empty = execute_plan_get(&l, false, 20);
690        assert!(empty.starts_with("no active plan"), "{empty}");
691        assert!(empty.contains("update_plan next"), "{empty}");
692        assert!(
693            empty.contains("do not call plan_get again"),
694            "empty plan_get must steer away from polling: {empty}"
695        );
696        assert_eq!(l.count(), 0, "plan_get does not mutate the ledger");
697        // a ledger with steps → the compiled <plan> block, read-only.
698        l.set_plan(&["scope it".to_string(), "build it".to_string()]);
699        let block = execute_plan_get(&l, false, 20);
700        assert!(
701            block.starts_with("<plan>\n") && block.ends_with("</plan>"),
702            "{block}"
703        );
704        assert!(block.contains("→ 1. scope it"), "{block}");
705        assert!(block.contains("☐ 2. build it"), "{block}");
706        assert_eq!(l.count(), 2, "plan_get is read-only");
707    }
708}