1use super::display::{print_tool_call, print_tool_output};
20use serde::{Deserialize, Serialize};
21use std::sync::Mutex;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum StepStatus {
26 Todo,
27 Active,
28 Done,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct Step {
34 pub description: String,
35 pub status: StepStatus,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54pub struct PlanSnapshot {
55 #[serde(default)]
58 pub steps: Vec<Step>,
59}
60
61impl PlanSnapshot {
62 #[must_use]
65 pub fn is_empty(&self) -> bool {
66 self.steps.is_empty()
67 }
68 #[must_use]
70 pub fn len(&self) -> usize {
71 self.steps.len()
72 }
73}
74
75pub(crate) const MAX_STEPS: usize = 30;
77pub(crate) const STEP_DESC_CAP: usize = 200;
79pub(crate) const PLAN_TOTAL_CAP: usize = 3_000;
81
82pub trait StepLedger: Send + Sync {
86 fn set_plan(&self, steps: &[String]) -> usize;
90 fn advance(&self) -> Option<String>;
93 fn steps(&self) -> Vec<Step>;
95 fn count(&self) -> u64;
97 fn done_count(&self) -> u64;
99 fn clear(&self);
101 fn snapshot(&self) -> PlanSnapshot;
105 fn restore(&self, snap: &PlanSnapshot);
109}
110
111#[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 if let Some(active) = steps.iter_mut().find(|s| s.status == StepStatus::Active) {
142 active.status = StepStatus::Done;
143 }
144 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
189pub(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
219pub fn plan_block(ledger: &dyn StepLedger) -> Option<String> {
222 build_plan_block(ledger, PLAN_TOTAL_CAP)
223}
224
225#[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; }
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
256pub 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
304pub 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
319fn 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, }
333}
334
335fn 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; }
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
357pub(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
404pub(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 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 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 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 assert_eq!(l.advance(), None);
464 assert_eq!(l.done_count(), 2);
465 assert_eq!(l.advance(), None);
467 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 let l = SessionStepLedger::default();
478 l.set_plan(&["a".to_string(), "b".to_string(), "c".to_string()]);
479 l.advance(); 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 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 assert_eq!(
493 serde_json::from_str::<PlanSnapshot>("{}").unwrap(),
494 PlanSnapshot::default(),
495 "the `{{}}` backfill parses to an empty snapshot"
496 );
497
498 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 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 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(); 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(); 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(); 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 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 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 let l = SessionStepLedger::default();
580 assert!(execute_update_plan(&serde_json::json!({}), &l, false, 20).starts_with("error:"));
582 assert_eq!(l.count(), 0);
583 assert!(
585 execute_update_plan(&serde_json::json!({"plan": []}), &l, false, 20)
586 .starts_with("error:")
587 );
588 assert_eq!(l.count(), 0);
589 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 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 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 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 assert!(
675 update_plan_tool_definition()["function"]["parameters"]["properties"]["plan"]
676 .is_object()
677 );
678 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 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 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}