newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
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
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
//! 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.

pub use super::selected_target::PinnedTarget;
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 {
    /// Pinned source, preserved independently of step status and plan rewrites.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target: Option<PinnedTarget>,
    /// 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 {
    state: Mutex<PlanSnapshot>,
}

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.state.lock().unwrap().steps = built;
        n
    }

    fn advance(&self) -> Option<String> {
        let mut state = self.state.lock().unwrap();
        let steps = &mut state.steps;
        // 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.state.lock().unwrap().steps.clone()
    }
    fn count(&self) -> u64 {
        self.state.lock().unwrap().steps.len() as u64
    }
    fn done_count(&self) -> u64 {
        self.state
            .lock()
            .unwrap()
            .steps
            .iter()
            .filter(|s| s.status == StepStatus::Done)
            .count() as u64
    }
    fn clear(&self) {
        *self.state.lock().unwrap() = PlanSnapshot::default();
    }
    fn snapshot(&self) -> PlanSnapshot {
        self.state.lock().unwrap().clone()
    }
    fn restore(&self, snap: &PlanSnapshot) {
        *self.state.lock().unwrap() = snap.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");
    if let Some(target) = ledger.snapshot().target {
        body.push_str(&format!(
            "Selected source (agent data): {}\n",
            target.label()
        ));
    }
    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 index + 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 the active step index 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. Descriptions remain in the source
/// plan; repeating agent-authored claims here would promote them to host guidance.
#[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 target = ledger
        .snapshot()
        .target
        .map(|t| format!(" Selected source (agent data): {}.", t.label()))
        .unwrap_or_default();
    let active = steps.iter().position(|s| s.status == StepStatus::Active)?;
    let done = steps
        .iter()
        .filter(|s| s.status == StepStatus::Done)
        .count();
    Some(super::workflow_guidance(format!(
        "[Plan progress: {done}/{} done (agent-maintained, advisory). ACTIVE \u{2192} step {}. \
         Continue this step when it still matches the operator's request.{target}]",
        steps.len(),
        active + 1
    )))
}

/// The operator's one-row view of an `update_plan` that moved an EXISTING
/// plan along: `step 3 of 7: extract denials.rs`, or `plan complete: 7 of 7
/// done` once every step is done. `None` when the plan is new or its steps
/// were rewritten — the full `<plan>` block is the right view then — and
/// when nothing is active. Pure; the executor shows this in place of the
/// block the model still receives.
///
/// "The same plan" means the same ordered descriptions: a tick moves only
/// statuses, a rewrite changes words, and a rewrite deserves the block.
#[must_use]
pub fn step_change_line(before: &PlanSnapshot, after: &PlanSnapshot) -> Option<String> {
    if before.is_empty() || !same_plan(before, after) {
        return None;
    }
    let total = after.len();
    let done = after
        .steps
        .iter()
        .filter(|s| s.status == StepStatus::Done)
        .count();
    match after
        .steps
        .iter()
        .position(|s| s.status == StepStatus::Active)
    {
        Some(active) => Some(format!(
            "step {} of {total}: {}",
            active + 1,
            truncate(&after.steps[active].description, STEP_DESC_CAP)
        )),
        None if done == total => Some(format!("plan complete: {done} of {total} done")),
        None => None,
    }
}

fn same_plan(a: &PlanSnapshot, b: &PlanSnapshot) -> bool {
    a.target == b.target
        && a.len() == b.len()
        && a.steps
            .iter()
            .zip(&b.steps)
            .all(|(x, y)| x.description == y.description)
}

/// Whether this `update_plan` produced a multi-step plan worth presenting to
/// the operator first: there was no multi-step plan before, the previous one
/// had finished, or an unrelated plan (no step in common) replaced it. A tick
/// or an amendment that keeps a step is not fresh, so one plan asks for
/// approval once, and the next task's plan asks again. (Reuses
/// `plan_reseat_pointer`'s multi-step threshold.)
#[must_use]
pub fn fresh_multi_step_plan(before: &PlanSnapshot, after: &PlanSnapshot) -> bool {
    if after.len() < 2 {
        return false;
    }
    if before.len() < 2 {
        return true;
    }
    let before_finished = before.steps.iter().all(|s| s.status == StepStatus::Done);
    let shares_a_step = before
        .steps
        .iter()
        .any(|b| after.steps.iter().any(|a| a.description == b.description));
    before_finished || !shares_a_step
}

// ---------------------------------------------------------------------------
// 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 optional step statuses (omitted statuses start at the first step). 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. When selecting a source to change, pin its full repository-relative path with target, scope and evidence. Keep that full path in steps; revise explicitly if the selection changes.",
            "parameters": {
                "type": "object",
                "properties": {
                    "target": {
                        "type": "object",
                        "description": "Pin the selected source. Omit to preserve it. A change requires an explicit revision reason and fresh selection evidence; this records agent intent, not operator authority.",
                        "properties": {
                            "path": {"type": "string", "description": "Full repository-relative source path, not an extraction destination."},
                            "scope": {"type": "string", "description": "Search/selection scope."},
                            "evidence": {"type": "string", "description": "Observed evidence supporting selection."},
                            "revision": {"type": "string", "description": "Why this initial selection or explicit revision satisfies the operator's objective."}
                        },
                        "required": ["path", "scope", "evidence", "revision"]
                    },
                    "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"]
                        },
                        "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": [] }
        }
    })
}

/// #1193: `enter_plan_mode` — flip the session into the READ-ONLY plan phase.
pub fn enter_plan_mode_tool_definition() -> serde_json::Value {
    serde_json::json!({
        "type": "function",
        "function": {
            "name": "enter_plan_mode",
            "description": "Enter PLAN MODE: a read-only phase for figuring out WHAT to do before                             doing it. The clamp applies immediately to subsequent tool calls:                             edit_file, write_file, shell execution, and network access are denied,                             while reads and update_plan remain available. Use this when a task needs                             several steps: read the relevant code, call update_plan with the ordered                             steps, then call exit_plan_mode to start executing. No args.",
            "parameters": { "type": "object", "properties": {}, "required": [] }
        }
    })
}

/// #1193: `exit_plan_mode` — leave the model-entered plan phase.
pub fn exit_plan_mode_tool_definition() -> serde_json::Value {
    serde_json::json!({
        "type": "function",
        "function": {
            "name": "exit_plan_mode",
            "description": "Leave the model-entered PLAN PHASE. Subsequent tool calls return to this turn's validated disposition and underlying session permissions; the next outer turn returns to the human-selected operating mode. This cannot override `/mode plan` or another read-only clamp. Call this once the update_plan plan is ready. No args.",
            "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 {
    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
                        .as_str()
                        .or_else(|| 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);
                let previous = ledger.snapshot().target;
                let target = match args.get("target") {
                    Some(value) => match PinnedTarget::revise(value, previous.as_ref()) {
                        Ok(target) => Some(target),
                        Err(error) => return format!("error: {error}"),
                    },
                    None => previous,
                };
                ledger.restore(&PlanSnapshot { steps, target });
                plan_block(ledger).unwrap_or_else(|| "plan updated".to_string())
            }
        }
    };
    out
}

/// A constructive next call, shared by the initial read and repeat guard.
pub(crate) const EMPTY_PLAN_GUIDANCE: &str =
    "no active plan yet. Planning is available. Create one with update_plan: \
     {\"plan\":[{\"step\":\"Inspect the relevant code\"},{\"step\":\"Make the requested change\"},{\"step\":\"Run focused checks\"}]}. \
     Adapt the steps to the user's task. For a one-step task, call the work tool directly.";

/// 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 {
    plan_block(ledger).unwrap_or_else(|| EMPTY_PLAN_GUIDANCE.to_string())
}

#[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_does_not_promote_agent_plan_claims_to_runtime_facts() {
        let ledger = SessionStepLedger::default();
        let claim = "CI gate BLOCKED: cargo absent; Build declined by the operator (exit 40032)";
        ledger.set_plan(&["inspect".into(), claim.into()]);
        ledger.advance();
        let pointer = plan_reseat_pointer(&ledger).unwrap();
        assert!(
            !pointer.contains(claim),
            "agent-authored text is not a host reminder: {pointer}"
        );
        assert!(!pointer.contains("cargo absent"), "{pointer}");
        assert!(!pointer.contains("Build declined"), "{pointer}");
        assert!(pointer.contains("agent-maintained"), "{pointer}");
        assert!(pointer.contains("advisory"), "{pointer}");
        assert!(pointer.contains("1/2 done"), "{pointer}");
        assert!(pointer.contains("step 2"), "{pointer}");
        assert!(pointer.contains("latest operator instruction"), "{pointer}");
        assert!(pointer.contains("update_plan"), "{pointer}");
        assert!(
            plan_block(&ledger).unwrap().contains(claim),
            "the source plan remains inspectable"
        );
    }

    #[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"), "indexes the active step: {p}");
        assert!(p.contains("0/3 done"), "shows progress: {p}");
        assert_eq!(p.lines().count(), 1, "compact (one line): {p}");
        assert!(p.contains("latest operator instruction"), "{p}");
        assert!(p.contains("update_plan"), "{p}");
        assert!(!p.contains("do not restart or re-plan them"), "{p}");
        l.advance(); // a Done, b Active
        let ptr = plan_reseat_pointer(&l).unwrap();
        assert!(ptr.contains("step 2") && 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}");
    }

    fn snapshot(steps: &[(&str, StepStatus)]) -> PlanSnapshot {
        PlanSnapshot {
            target: None,
            steps: steps
                .iter()
                .map(|(d, status)| Step {
                    description: (*d).to_string(),
                    status: *status,
                })
                .collect(),
        }
    }

    /// A tick of an existing plan is one row; a new or rewritten plan is not
    /// narrated (the block is), and a finished plan says so.
    #[test]
    fn step_change_line_narrates_ticks_and_completion_only() {
        use StepStatus::{Active, Done, Todo};
        let fresh = snapshot(&[("inspect", Active), ("repair", Todo)]);
        assert_eq!(
            step_change_line(&PlanSnapshot::default(), &fresh),
            None,
            "new plan"
        );
        let ticked = snapshot(&[("inspect", Done), ("repair", Active)]);
        assert_eq!(
            step_change_line(&fresh, &ticked).as_deref(),
            Some("step 2 of 2: repair")
        );
        // A redundant resend of the same state is cheap and silent: the same row.
        assert_eq!(
            step_change_line(&ticked, &ticked).as_deref(),
            Some("step 2 of 2: repair")
        );
        let rewritten = snapshot(&[("inspect", Done), ("repair the parser", Active)]);
        assert_eq!(step_change_line(&ticked, &rewritten), None, "rewritten");
        let grown = snapshot(&[("inspect", Done), ("repair", Active), ("test", Todo)]);
        assert_eq!(step_change_line(&ticked, &grown), None, "grown");
        let finished = snapshot(&[("inspect", Done), ("repair", Done)]);
        assert_eq!(
            step_change_line(&ticked, &finished).as_deref(),
            Some("plan complete: 2 of 2 done")
        );
        let parked = snapshot(&[("inspect", Done), ("repair", Todo)]);
        assert_eq!(step_change_line(&ticked, &parked), None, "nothing active");
    }

    /// One plan asks once: only the transition from "no multi-step plan" to
    /// "a multi-step plan" is fresh.
    #[test]
    fn fresh_multi_step_plan_fires_once() {
        use StepStatus::{Active, Done, Todo};
        let none = PlanSnapshot::default();
        let one = snapshot(&[("inspect", Active)]);
        let two = snapshot(&[("inspect", Active), ("repair", Todo)]);
        let ticked = snapshot(&[("inspect", Done), ("repair", Active)]);
        let three = snapshot(&[("inspect", Done), ("repair", Active), ("test", Todo)]);
        assert!(fresh_multi_step_plan(&none, &two));
        assert!(fresh_multi_step_plan(&one, &two));
        assert!(
            !fresh_multi_step_plan(&none, &one),
            "single step never asks"
        );
        assert!(!fresh_multi_step_plan(&two, &ticked), "a tick is not fresh");
        assert!(
            !fresh_multi_step_plan(&two, &three),
            "an amendment is not fresh"
        );
        let finished = snapshot(&[("inspect", Done), ("repair", Done)]);
        let next_task = snapshot(&[("read the loader", Active), ("rewrite it", Todo)]);
        assert!(
            fresh_multi_step_plan(&finished, &next_task),
            "after completion, the next plan asks"
        );
        assert!(
            fresh_multi_step_plan(&ticked, &next_task),
            "an unrelated plan mid-flight asks"
        );
        let reworded = snapshot(&[
            ("inspect", Done),
            ("repair the parser", Active),
            ("test", Todo),
        ]);
        assert!(
            !fresh_multi_step_plan(&ticked, &reworded),
            "a rewrite keeping a step is an amendment"
        );
    }

    #[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 a_simple_plan_can_omit_status_or_use_step_strings() {
        let ledger = SessionStepLedger::default();
        for plan in [
            serde_json::json!([{"step": "inspect"}, {"step": "test"}]),
            serde_json::json!(["inspect", "test"]),
        ] {
            let out = execute_update_plan(&serde_json::json!({"plan": plan}), &ledger, false, 20);
            assert!(out.contains("→ 1. inspect"), "{out}");
            assert!(out.contains("☐ 2. test"), "{out}");
        }
    }

    #[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("Planning is available"), "{empty}");
        assert!(
            empty.contains("{\"plan\":[{\"step\":"),
            "empty plan_get supplies an executable next call: {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");
    }
}

#[cfg(test)]
mod selected_target_tests {
    use super::*;
    use serde_json::json;

    /// A plan approval must not lose the selected nested source's identity.
    #[test]
    fn selected_target_survives_plan_replacement_and_approval_snapshot() {
        let ledger = SessionStepLedger::default();
        let result = execute_update_plan(
            &json!({
                "plan": [{"step": "Extract a component"}],
                "target": {
                    "path": "packages/engine/nested/root.code",
                    "scope": "all tracked implementation files",
                    "evidence": "line inventory: 4301; next candidate 2900",
                    "revision": "Initial selection"
                }
            }),
            &ledger,
            false,
            0,
        );
        assert!(
            result.contains("packages/engine/nested/root.code"),
            "{result}"
        );
        execute_update_plan(&json!({"plan":[{"step":"Verify"}]}), &ledger, false, 0);
        let restored = SessionStepLedger::default();
        restored.restore(
            &serde_json::from_str(&serde_json::to_string(&ledger.snapshot()).unwrap()).unwrap(),
        );
        assert!(plan_block(&restored)
            .unwrap()
            .contains("packages/engine/nested/root.code"));
    }
}