newt-core 0.7.1

Newt-Agent core types, errors, and the NeMoCode-style tier router
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
//! Scheduled context — the `scheduled` context feature (Step 26.6b, #586).
//!
//! A "compiled per-step view": instead of leaning on an ever-growing chat
//! buffer, the model keeps an ORDERED plan of steps (each Todo / Active / Done)
//! and a compiled `<plan>` checklist is injected at the head of every turn. The
//! model maintains it with `update_plan` (send the full ordered list each time,
//! each step tagged pending/in_progress/completed), so it stays oriented around
//! its plan's progress rather than re-deriving it from a long transcript.
//!
//! Pure in-memory + deterministic (a `Vec`, no clock/uuid), so the whole feature
//! unit-tests with zero network/fs. Mirrors `scratchpad.rs`: a `&self`
//! interior-mutability trait + an in-memory impl. The plan is task-specific →
//! cleared on `/new`.
//!
//! Out of scope (a future enhancement): actually RESETTING the rolling window
//! around the compiled view. v1 injects the `<plan>` view alongside the normal
//! window rather than replacing it — honest + non-invasive.

use super::display::{print_tool_call, print_tool_output};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;

/// A plan step's progress.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StepStatus {
    Todo,
    Active,
    Done,
}

/// One step in the plan.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Step {
    pub description: String,
    pub status: StepStatus,
}

/// A serializable full-state snapshot of the plan ledger (#715). Persisted onto
/// the conversation row so an interrupt + auto-resume can re-hydrate the
/// in-memory ledger EXACTLY — the ordered steps AND each step's done/active
/// status — instead of rebuilding it empty (which loses the `<plan>` block and
/// `plan_get` after a resume). The active step is encoded by its
/// [`StepStatus::Active`] marker, so the snapshot carries no separate index to
/// drift out of sync.
///
/// Unlike [`StepLedger::set_plan`] (which takes bare step strings and RESETS
/// every status — the first step Active, the rest Todo), a snapshot/restore
/// round-trip preserves which steps are Done and which is Active.
///
/// Additive + **working memory, not provenance**: it rides the conversation row
/// (never a turn), is kept OUT of the §6 content chain, and an empty snapshot is
/// skipped on serialize so it never bloats the on-disk file.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanSnapshot {
    /// The ordered steps with each one's status. `#[serde(default)]` so the
    /// historically-true empty backfill (`{}`) on an older db parses cleanly.
    #[serde(default)]
    pub steps: Vec<Step>,
}

impl PlanSnapshot {
    /// Whether the snapshot holds no steps (the OFF/empty case — used by the
    /// record's `skip_serializing_if` and the resume banner).
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.steps.is_empty()
    }
    /// Number of steps in the snapshot.
    #[must_use]
    pub fn len(&self) -> usize {
        self.steps.len()
    }
}

/// Max steps a plan may hold (a guard; extra steps are dropped on set).
pub(crate) const MAX_STEPS: usize = 30;
/// Per-step description cap in the `<plan>` view.
pub(crate) const STEP_DESC_CAP: usize = 200;
/// Whole-block char cap so a long plan can never blow the send budget.
pub(crate) const PLAN_TOTAL_CAP: usize = 3_000;

/// A session store for the ordered plan (Step 26.6b). `&self` methods (interior
/// mutability) so one shared `&dyn StepLedger` serves both the per-turn `<plan>`
/// injection and the update_plan tool. Task-specific → cleared on `/new`.
pub trait StepLedger: Send + Sync {
    /// Replace the plan with a fresh ordered list — the first step becomes
    /// Active, the rest Todo. Blank descriptions are dropped; capped at
    /// [`MAX_STEPS`]. Returns the number of steps actually set.
    fn set_plan(&self, steps: &[String]) -> usize;
    /// Mark the Active step Done and activate the next Todo. Returns the new
    /// active step's description, or `None` when the plan is complete/empty.
    fn advance(&self) -> Option<String>;
    /// A snapshot of the steps (for the `<plan>` block).
    fn steps(&self) -> Vec<Step>;
    /// Total steps (for `/context stats`).
    fn count(&self) -> u64;
    /// Completed steps (for `/context stats`).
    fn done_count(&self) -> u64;
    /// Drop the plan (`/new`).
    fn clear(&self);
    /// A full-state [`PlanSnapshot`] for persistence/resume (#715): the ordered
    /// steps with each one's status, so a later [`Self::restore`] reinstates the
    /// ledger EXACTLY (unlike [`Self::set_plan`], which resets every status).
    fn snapshot(&self) -> PlanSnapshot;
    /// Restore the ledger from a [`PlanSnapshot`] (#715) — replaces the steps
    /// and their statuses verbatim (a full clear + replace, the inverse of
    /// [`Self::snapshot`]). An empty snapshot leaves the ledger empty.
    fn restore(&self, snap: &PlanSnapshot);
}

/// In-memory, session-scoped [`StepLedger`] — pure (no fs). A `Vec` in plan
/// order; deterministic (no clock/uuid) for stable tests.
#[derive(Default)]
pub struct SessionStepLedger {
    steps: Mutex<Vec<Step>>,
}

impl StepLedger for SessionStepLedger {
    fn set_plan(&self, steps: &[String]) -> usize {
        let mut built: Vec<Step> = steps
            .iter()
            .map(|s| s.trim())
            .filter(|s| !s.is_empty())
            .take(MAX_STEPS)
            .map(|s| Step {
                description: s.to_string(),
                status: StepStatus::Todo,
            })
            .collect();
        if let Some(first) = built.first_mut() {
            first.status = StepStatus::Active;
        }
        let n = built.len();
        *self.steps.lock().unwrap() = built;
        n
    }

    fn advance(&self) -> Option<String> {
        let mut steps = self.steps.lock().unwrap();
        // Finish the current Active step (if any).
        if let Some(active) = steps.iter_mut().find(|s| s.status == StepStatus::Active) {
            active.status = StepStatus::Done;
        }
        // Activate the next Todo, if one remains.
        if let Some(next) = steps.iter_mut().find(|s| s.status == StepStatus::Todo) {
            next.status = StepStatus::Active;
            Some(next.description.clone())
        } else {
            None
        }
    }

    fn steps(&self) -> Vec<Step> {
        self.steps.lock().unwrap().clone()
    }
    fn count(&self) -> u64 {
        self.steps.lock().unwrap().len() as u64
    }
    fn done_count(&self) -> u64 {
        self.steps
            .lock()
            .unwrap()
            .iter()
            .filter(|s| s.status == StepStatus::Done)
            .count() as u64
    }
    fn clear(&self) {
        self.steps.lock().unwrap().clear();
    }
    fn snapshot(&self) -> PlanSnapshot {
        PlanSnapshot {
            steps: self.steps.lock().unwrap().clone(),
        }
    }
    fn restore(&self, snap: &PlanSnapshot) {
        *self.steps.lock().unwrap() = snap.steps.clone();
    }
}

fn truncate(s: &str, cap: usize) -> String {
    if s.chars().count() <= cap {
        s.to_string()
    } else {
        let head: String = s.chars().take(cap).collect();
        format!("{head}[…]")
    }
}

/// Render the compiled `<plan>` checklist injected at the head of a turn (Step
/// 26.6b). `None` when the plan is empty — the OFF/empty bit-for-bit guarantee
/// (mirror `scratchpad::build_state_block`). `✓` done, `→` active, `☐` todo.
pub(crate) fn build_plan_block(ledger: &dyn StepLedger, total_cap: usize) -> Option<String> {
    let steps = ledger.steps();
    if steps.is_empty() {
        return None;
    }
    let mut body = String::from("<plan>\n");
    for (i, step) in steps.iter().enumerate() {
        let mark = match step.status {
            StepStatus::Done => "",
            StepStatus::Active => "",
            StepStatus::Todo => "",
        };
        let piece = format!(
            "{mark} {}. {}\n",
            i + 1,
            truncate(&step.description, STEP_DESC_CAP)
        );
        if body.chars().count() + piece.chars().count() + "</plan>".len() > total_cap {
            body.push_str("[… plan truncated to fit the budget …]\n");
            break;
        }
        body.push_str(&piece);
    }
    body.push_str("</plan>");
    Some(body)
}

/// TUI-facing entry: the compiled `<plan>` block with the default cap (Step
/// 26.6b). `None` when the plan is empty.
pub fn plan_block(ledger: &dyn StepLedger) -> Option<String> {
    build_plan_block(ledger, PLAN_TOTAL_CAP)
}

/// A COMPACT per-round re-seat pointer — the active step + progress on one line.
///
/// `None` unless a MULTI-STEP plan is in progress (≥2 steps and an Active step
/// remains). This is the conditional re-seat validated by the dgx1 12-step A/B
/// (re-seat **12/12** vs baseline **8/12** under drift; see
/// `docs/research/weak-model-plan-mode-findings.md`): the round-0 `<plan>`
/// snapshot goes stale across the up-to-N tool rounds, so a weak model "loses
/// track of its plan over time". Re-showing only the active step each round keeps
/// it on course at a fraction of the token cost of re-injecting the full `<plan>`
/// (which mildly *hurt* on no-drift tasks). Gated to multi-step in-progress plans
/// so trivial/single-step turns pay nothing.
#[must_use]
pub fn plan_reseat_pointer(ledger: &dyn StepLedger) -> Option<String> {
    let steps = ledger.steps();
    if steps.len() < 2 {
        return None; // no plan, or a single-step plan that needs no re-seat
    }
    let active = steps.iter().position(|s| s.status == StepStatus::Active)?;
    let done = steps
        .iter()
        .filter(|s| s.status == StepStatus::Done)
        .count();
    Some(format!(
        "[Plan progress: {done}/{} done. ACTIVE \u{2192} step {}: {}. Keep working \
         THIS step; earlier steps are already done — do not restart or re-plan them.]",
        steps.len(),
        active + 1,
        truncate(&steps[active].description, STEP_DESC_CAP)
    ))
}

// ---------------------------------------------------------------------------
// Tool schemas (advertised only when the feature is on + a ledger is present)
// ---------------------------------------------------------------------------

/// `update_plan` tool definition (#715 PR2) — the single WRITE surface that
/// replaces `plan_set` + `plan_advance`. The Codex shape: send the full ordered
/// list each turn, each step tagged with its status (exactly one `in_progress`).
pub fn update_plan_tool_definition() -> serde_json::Value {
    serde_json::json!({
        "type": "function",
        "function": {
            "name": "update_plan",
            "description": "Create or update your plan — send the full ordered list each time \
                            with each step's status. A <plan> checklist is shown at the head of \
                            every turn; mark the step you are on \"in_progress\" and finished \
                            steps \"completed\". Prefer this before more investigation for \
                            multi-step, ambiguous, resumed, or context-compacted work. Replaces \
                            plan_set/plan_advance.",
            "parameters": {
                "type": "object",
                "properties": {
                    "plan": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "step": {
                                    "type": "string",
                                    "description": "A short imperative phrase for the step."
                                },
                                "status": {
                                    "type": "string",
                                    "enum": ["pending", "in_progress", "completed"],
                                    "description": "The step's progress; exactly one step \
                                                    should be in_progress."
                                }
                            },
                            "required": ["step", "status"]
                        },
                        "description": "The ordered steps, each with its status."
                    }
                },
                "required": ["plan"]
            }
        }
    })
}

/// `plan_get` tool definition (#716) — a read-only read of the current plan.
pub fn plan_get_tool_definition() -> serde_json::Value {
    serde_json::json!({
        "type": "function",
        "function": {
            "name": "plan_get",
            "description": "Read the current <plan> checklist — the ordered steps and which is \
                            active. No args. Use it to recover what you were working on after a \
                            resume; if it reports no active plan, create one with update_plan \
                            instead of calling plan_get again.",
            "parameters": { "type": "object", "properties": {}, "required": [] }
        }
    })
}

// ---------------------------------------------------------------------------
// Executors (every branch returns a tool-result String, never a loop abort)
// ---------------------------------------------------------------------------

/// Map a tool-supplied `status` string onto a [`StepStatus`]. Lenient: the
/// schema's canonical `pending`/`in_progress`/`completed` plus their obvious
/// synonyms; anything unrecognized (or missing) falls back to `Todo` so a noisy
/// status never aborts the call.
fn parse_step_status(raw: &str) -> StepStatus {
    match raw.trim().to_ascii_lowercase().as_str() {
        "completed" | "complete" | "done" => StepStatus::Done,
        "in_progress" | "in-progress" | "active" | "current" => StepStatus::Active,
        _ => StepStatus::Todo, // "pending" / "todo" / unknown
    }
}

/// Enforce the Codex invariant of EXACTLY ONE active step — leniently. If the
/// model sent no `in_progress` (or several), the first non-completed step is
/// made active and any other actives demoted to Todo. An all-completed plan is
/// left with no active step (the plan is done).
fn normalize_active(steps: &mut [Step]) {
    let active = steps
        .iter()
        .filter(|s| s.status == StepStatus::Active)
        .count();
    if active == 1 {
        return; // the well-formed case — respect the model's choice
    }
    for s in steps.iter_mut() {
        if s.status == StepStatus::Active {
            s.status = StepStatus::Todo;
        }
    }
    if let Some(first) = steps.iter_mut().find(|s| s.status != StepStatus::Done) {
        first.status = StepStatus::Active;
    }
}

/// Execute an `update_plan` call (#715 PR2) — the single WRITE surface. Parses
/// the `{plan:[{step,status}]}` array into a [`PlanSnapshot`] and reinstates the
/// ledger via [`StepLedger::restore`] (the same full-state path persistence uses,
/// so statuses land verbatim). Returns the rendered `<plan>` block so the model
/// sees the result; read it back later with `plan_get`.
pub(crate) fn execute_update_plan(
    args: &serde_json::Value,
    ledger: &dyn StepLedger,
    color: bool,
    tool_output_lines: usize,
) -> String {
    print_tool_call("update_plan", "", color);
    let out = match args["plan"].as_array() {
        None => "error: update_plan requires a `plan` array of {\"step\",\"status\"} objects"
            .to_string(),
        Some(items) => {
            let mut steps: Vec<Step> = items
                .iter()
                .filter_map(|it| {
                    let desc = it.get("step").and_then(|v| v.as_str())?.trim();
                    if desc.is_empty() {
                        return None;
                    }
                    let status = it
                        .get("status")
                        .and_then(|v| v.as_str())
                        .map_or(StepStatus::Todo, parse_step_status);
                    Some(Step {
                        description: desc.to_string(),
                        status,
                    })
                })
                .take(MAX_STEPS)
                .collect();
            if steps.is_empty() {
                "error: update_plan requires a non-empty `plan` array".to_string()
            } else {
                normalize_active(&mut steps);
                ledger.restore(&PlanSnapshot { steps });
                plan_block(ledger).unwrap_or_else(|| "plan updated".to_string())
            }
        }
    };
    print_tool_output(&out, tool_output_lines, color);
    out
}

/// Execute a `plan_get` call (#716) — render the current `<plan>` checklist
/// read-only (no ledger mutation), so a resumed turn can recover "what was I
/// working on". Empty plan → a hint to start one with `update_plan`.
pub(crate) fn execute_plan_get(
    ledger: &dyn StepLedger,
    color: bool,
    tool_output_lines: usize,
) -> String {
    print_tool_call("plan_get", "", color);
    let out = plan_block(ledger).unwrap_or_else(|| {
        "no active plan — if this is multi-step, ambiguous, resumed, or context-compacted work, \
         call update_plan next with a short 2-6 step ordered plan using statuses \
         pending/in_progress/completed; do not call plan_get again until you have created or \
         updated a plan"
            .to_string()
    });
    print_tool_output(&out, tool_output_lines, color);
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn set_plan_filters_blanks_caps_and_activates_first() {
        let l = SessionStepLedger::default();
        let n = l.set_plan(&[
            "  read the code  ".to_string(),
            "".to_string(),
            "  ".to_string(),
            "write the fix".to_string(),
        ]);
        assert_eq!(n, 2, "blank steps dropped");
        let steps = l.steps();
        assert_eq!(steps[0].description, "read the code", "trimmed");
        assert_eq!(steps[0].status, StepStatus::Active, "first is active");
        assert_eq!(steps[1].status, StepStatus::Todo);
        // cap at MAX_STEPS
        let many: Vec<String> = (0..MAX_STEPS + 10).map(|i| format!("step {i}")).collect();
        assert_eq!(l.set_plan(&many), MAX_STEPS, "capped at MAX_STEPS");
        // clear empties the plan (/new path)
        l.clear();
        assert_eq!(l.count(), 0);
        assert!(l.steps().is_empty());
    }

    #[test]
    fn advance_walks_active_to_done_and_reports_complete() {
        let l = SessionStepLedger::default();
        l.set_plan(&["a".to_string(), "b".to_string()]);
        assert_eq!(l.done_count(), 0);
        // advance: a → Done, b → Active, returns "b"
        assert_eq!(l.advance().as_deref(), Some("b"));
        let steps = l.steps();
        assert_eq!(steps[0].status, StepStatus::Done);
        assert_eq!(steps[1].status, StepStatus::Active);
        assert_eq!(l.done_count(), 1);
        // advance again: b → Done, no Todo left → None (complete)
        assert_eq!(l.advance(), None);
        assert_eq!(l.done_count(), 2);
        // advancing a complete plan stays None, no panic
        assert_eq!(l.advance(), None);
        // advancing an empty plan → None
        let empty = SessionStepLedger::default();
        assert_eq!(empty.advance(), None);
    }

    #[test]
    fn snapshot_restore_round_trips_full_state_unlike_set_plan() {
        // #715: a snapshot captures the FULL state (steps + per-step status), so
        // restore reinstates which steps are Done / Active — the resume-safety
        // property set_plan lacks (it resets every status).
        let l = SessionStepLedger::default();
        l.set_plan(&["a".to_string(), "b".to_string(), "c".to_string()]);
        l.advance(); // a → Done, b → Active
        let snap = l.snapshot();
        assert_eq!(snap.len(), 3);
        assert!(!snap.is_empty());
        assert_eq!(snap.steps[0].status, StepStatus::Done);
        assert_eq!(snap.steps[1].status, StepStatus::Active);
        assert_eq!(snap.steps[2].status, StepStatus::Todo);

        // It survives a serde round-trip (the persistence path).
        let json = serde_json::to_string(&snap).unwrap();
        let decoded: PlanSnapshot = serde_json::from_str(&json).unwrap();
        assert_eq!(decoded, snap, "snapshot must survive serde verbatim");
        // The `{}` backfill (an older db / OFF feature) parses to an empty plan.
        assert_eq!(
            serde_json::from_str::<PlanSnapshot>("{}").unwrap(),
            PlanSnapshot::default(),
            "the `{{}}` backfill parses to an empty snapshot"
        );

        // Restore into a FRESH ledger reinstates the exact state — not a reset.
        let fresh = SessionStepLedger::default();
        fresh.restore(&decoded);
        assert_eq!(fresh.snapshot(), snap, "restore reinstates verbatim");
        assert_eq!(fresh.done_count(), 1, "the Done step survives restore");
        let block = build_plan_block(&fresh, 3000).unwrap();
        assert!(block.contains("✓ 1. a"), "{block}");
        assert!(block.contains("→ 2. b"), "{block}");
        assert!(block.contains("☐ 3. c"), "{block}");
        // Contrast: set_plan would have reset b/c to Active/Todo and lost a's Done.
        let reset = SessionStepLedger::default();
        reset.set_plan(&["a".to_string(), "b".to_string(), "c".to_string()]);
        assert_eq!(reset.done_count(), 0, "set_plan resets — proving the gap");

        // Restoring an empty snapshot clears (a conversation boundary).
        fresh.restore(&PlanSnapshot::default());
        assert_eq!(
            fresh.count(),
            0,
            "an empty snapshot leaves the ledger empty"
        );
    }

    #[test]
    fn reseat_pointer_compact_and_gated_to_multistep_in_progress() {
        let l = SessionStepLedger::default();
        assert!(plan_reseat_pointer(&l).is_none(), "empty → none");
        l.set_plan(&["only".to_string()]);
        assert!(plan_reseat_pointer(&l).is_none(), "single step → none");
        l.set_plan(&["a".to_string(), "b".to_string(), "c".to_string()]);
        let p = plan_reseat_pointer(&l).expect("multi-step → pointer");
        assert!(p.contains("step 1: a"), "names the active step: {p}");
        assert!(p.contains("0/3 done"), "shows progress: {p}");
        assert_eq!(p.lines().count(), 1, "compact (one line): {p}");
        l.advance(); // a Done, b Active
        let ptr = plan_reseat_pointer(&l).unwrap();
        assert!(
            ptr.contains("step 2: b") && ptr.contains("1/3 done"),
            "{ptr}"
        );
        l.advance();
        l.advance(); // all done, no Active
        assert!(plan_reseat_pointer(&l).is_none(), "complete plan → none");
    }

    #[test]
    fn build_block_none_when_empty_marks_status_and_caps() {
        let l = SessionStepLedger::default();
        assert_eq!(build_plan_block(&l, 3000), None, "empty plan → no block");
        l.set_plan(&["alpha".to_string(), "beta".to_string(), "gamma".to_string()]);
        l.advance(); // alpha Done, beta Active
        let block = build_plan_block(&l, 3000).unwrap();
        assert!(block.starts_with("<plan>\n") && block.ends_with("</plan>"));
        assert!(block.contains("✓ 1. alpha"), "{block}");
        assert!(block.contains("→ 2. beta"), "{block}");
        assert!(block.contains("☐ 3. gamma"), "{block}");
        // a single over-long step description is truncated with the […] marker
        let long = SessionStepLedger::default();
        long.set_plan(&["x".repeat(STEP_DESC_CAP + 50)]);
        let lb = build_plan_block(&long, 3000).unwrap();
        assert!(
            lb.contains("[…]"),
            "per-step description truncated: {lb:.80}"
        );
        // total cap trips the marker
        let big = SessionStepLedger::default();
        let many: Vec<String> = (0..MAX_STEPS)
            .map(|i| format!("step number {i} with padding"))
            .collect();
        big.set_plan(&many);
        let capped = build_plan_block(&big, 200).unwrap();
        assert!(
            capped.chars().count() <= 200 + 60,
            "total cap bounds the block"
        );
        assert!(capped.contains("plan truncated"), "{capped}");
    }

    #[test]
    fn update_plan_sets_statuses_renders_block_and_is_lenient() {
        // #715 PR2: update_plan is the single WRITE surface ({plan:[{step,status}]}).
        let l = SessionStepLedger::default();
        // missing `plan` → coaching error, ledger untouched
        assert!(execute_update_plan(&serde_json::json!({}), &l, false, 20).starts_with("error:"));
        assert_eq!(l.count(), 0);
        // empty array → error, still untouched
        assert!(
            execute_update_plan(&serde_json::json!({"plan": []}), &l, false, 20)
                .starts_with("error:")
        );
        assert_eq!(l.count(), 0);
        // a full plan with one in_progress → statuses land in the ledger AND the
        // rendered <plan> block comes back.
        let out = execute_update_plan(
            &serde_json::json!({"plan": [
                {"step": "scope it", "status": "completed"},
                {"step": "build it", "status": "in_progress"},
                {"step": "test it",  "status": "pending"},
            ]}),
            &l,
            false,
            20,
        );
        assert!(
            out.starts_with("<plan>\n") && out.ends_with("</plan>"),
            "{out}"
        );
        assert!(out.contains("✓ 1. scope it"), "{out}");
        assert!(out.contains("→ 2. build it"), "{out}");
        assert!(out.contains("☐ 3. test it"), "{out}");
        let steps = l.steps();
        assert_eq!(steps[0].status, StepStatus::Done);
        assert_eq!(steps[1].status, StepStatus::Active);
        assert_eq!(steps[2].status, StepStatus::Todo);
        assert_eq!(
            steps
                .iter()
                .filter(|s| s.status == StepStatus::Active)
                .count(),
            1,
            "exactly one in_progress"
        );
        // lenient: NO in_progress → the first non-completed becomes active.
        let none_active = execute_update_plan(
            &serde_json::json!({"plan": [
                {"step": "a", "status": "completed"},
                {"step": "b", "status": "pending"},
                {"step": "c", "status": "pending"},
            ]}),
            &l,
            false,
            20,
        );
        assert!(
            none_active.contains("→ 2. b"),
            "first non-completed becomes active: {none_active}"
        );
        // lenient: MULTIPLE in_progress → only the first non-completed stays active.
        execute_update_plan(
            &serde_json::json!({"plan": [
                {"step": "x", "status": "in_progress"},
                {"step": "y", "status": "in_progress"},
            ]}),
            &l,
            false,
            20,
        );
        let s = l.steps();
        assert_eq!(
            s.iter().filter(|x| x.status == StepStatus::Active).count(),
            1,
            "multiple in_progress collapse to one"
        );
        assert_eq!(s[0].status, StepStatus::Active, "the first becomes active");
        // blank step descriptions are dropped (the set_plan filtering parity).
        let dropped = execute_update_plan(
            &serde_json::json!({"plan": [
                {"step": "  ", "status": "pending"},
                {"step": "real", "status": "in_progress"},
            ]}),
            &l,
            false,
            20,
        );
        assert!(dropped.contains("→ 1. real"), "{dropped}");
        assert_eq!(l.count(), 1, "blank step dropped");
    }

    #[test]
    fn tool_definitions_shape() {
        assert_eq!(
            update_plan_tool_definition()["function"]["name"],
            "update_plan"
        );
        assert_eq!(plan_get_tool_definition()["function"]["name"], "plan_get");
        // update_plan takes the `plan` array of {step,status} objects.
        assert!(
            update_plan_tool_definition()["function"]["parameters"]["properties"]["plan"]
                .is_object()
        );
        // #716: plan_get is read-only — no args.
        assert_eq!(
            plan_get_tool_definition()["function"]["parameters"]["properties"],
            serde_json::json!({})
        );
    }

    #[test]
    fn plan_get_renders_plan_or_hints_when_empty() {
        // #716: empty ledger → a hint to start a plan (no mutation).
        let l = SessionStepLedger::default();
        let empty = execute_plan_get(&l, false, 20);
        assert!(empty.starts_with("no active plan"), "{empty}");
        assert!(empty.contains("update_plan next"), "{empty}");
        assert!(
            empty.contains("do not call plan_get again"),
            "empty plan_get must steer away from polling: {empty}"
        );
        assert_eq!(l.count(), 0, "plan_get does not mutate the ledger");
        // a ledger with steps → the compiled <plan> block, read-only.
        l.set_plan(&["scope it".to_string(), "build it".to_string()]);
        let block = execute_plan_get(&l, false, 20);
        assert!(
            block.starts_with("<plan>\n") && block.ends_with("</plan>"),
            "{block}"
        );
        assert!(block.contains("→ 1. scope it"), "{block}");
        assert!(block.contains("☐ 2. build it"), "{block}");
        assert_eq!(l.count(), 2, "plan_get is read-only");
    }
}