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
//! Pure plan/checklist core — the agent's cross-turn memory of "what am I doing".
//!
//! Before this, an agent's ONLY record of a multi-phase objective was the raw
//! transcript, re-read on every auto-continue nudge. Worse, the system prompt
//! tells the model to "PLAN FIRST … post a SHORT plan in plain text" and promises
//! "you auto-continue after each step" — but a text-only turn classified as
//! `FinalAnswer` and BROKE the loop, so the agent posted its plan and silently
//! stopped at step one (telemetry #75/#69/#67).
//!
//! A plan with open steps is what lets `turn_flow::classify_turn` tell a
//! mid-plan narration turn ("here's what I'll do next") from a conversational
//! reply. Pure + native-tested; the browser holds one in a thread-local.
use serde::{Deserialize, Serialize};
/// Max steps in a plan. A plan is a working checklist, not a project tracker —
/// past ~a dozen the model is decomposing too finely to make progress.
pub const MAX_STEPS: usize = 12;
/// Max chars per step. Steps are labels ("wire the pointer routing"), not prose.
pub const MAX_STEP_LEN: usize = 120;
/// One checklist step.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Step {
pub text: String,
pub done: bool,
}
/// An ordered checklist the agent maintains across turns.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Plan {
pub steps: Vec<Step>,
}
impl Plan {
/// Build from the wire shape: the full ordered step list plus the indices
/// that are complete. Re-sent whole on every update (idempotent, so a
/// dropped turn can't desync it) — empty `steps` clears the plan.
///
/// Steps are trimmed, empties dropped, each truncated to `MAX_STEP_LEN` and
/// the list to `MAX_STEPS`; out-of-range `completed` indices are ignored.
///
/// ⛔ `completed` indexes the array the MODEL SENT, so blanks must be
/// enumerated BEFORE they are dropped. Filtering first renumbered the
/// survivors and silently shifted every index past a blank: `["a", "", "b"]`
/// with `completed: [2]` checked off NOTHING (index 2 no longer existed)
/// while the model believed "b" was done. Not cosmetic — an OPEN plan is
/// what keeps a text-only turn from ending the run
/// (`turn_flow::classify_turn`, telemetry #75/#69/#67), so a desynced
/// checklist changes when the agent stops.
pub fn from_wire(steps: &[String], completed: &[i64]) -> Self {
let steps: Vec<Step> = steps
.iter()
.enumerate()
.map(|(sent_idx, s)| (sent_idx, s.trim()))
.filter(|(_, s)| !s.is_empty())
.take(MAX_STEPS)
.map(|(sent_idx, s)| Step {
text: truncate(s, MAX_STEP_LEN),
done: completed.contains(&(sent_idx as i64)),
})
.collect();
Self { steps }
}
/// `(done, total)` — the "3/5" the user asked to see.
pub fn progress(&self) -> (usize, usize) {
(self.steps.iter().filter(|s| s.done).count(), self.steps.len())
}
/// Steps still open. `> 0` is what keeps the turn loop going through a
/// text-only turn (see `turn_flow::classify_turn`).
pub fn open(&self) -> usize {
self.steps.iter().filter(|s| !s.done).count()
}
/// A plan with work left. An empty or fully-checked plan is NOT active — it
/// must not hold the loop open past the last step.
pub fn is_active(&self) -> bool {
self.open() > 0
}
/// The first open step — what the agent should be doing right now.
pub fn current(&self) -> Option<&str> {
self.steps.iter().find(|s| !s.done).map(|s| s.text.as_str())
}
}
/// Truncate on a char boundary (never panic on multi-byte input).
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
s.chars().take(max).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn wire(steps: &[&str], completed: &[i64]) -> Plan {
Plan::from_wire(
&steps.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
completed,
)
}
#[test]
fn progress_counts_completed_indices() {
let p = wire(&["a", "b", "c", "d", "e"], &[0, 2]);
assert_eq!(p.progress(), (2, 5));
assert_eq!(p.open(), 3);
assert!(p.is_active());
}
#[test]
fn current_is_the_first_open_step() {
let p = wire(&["design", "build", "ship"], &[0]);
assert_eq!(p.current(), Some("build"));
}
/// The loop must NOT be held open by a finished or empty plan.
#[test]
fn fully_done_or_empty_plan_is_inactive() {
assert!(!wire(&["a", "b"], &[0, 1]).is_active());
assert!(!wire(&[], &[]).is_active());
assert_eq!(wire(&[], &[]).current(), None);
}
/// REGRESSION: `completed` indexes the array the MODEL SENT, so dropping
/// blanks before enumerating shifted every index past a blank. The old code
/// renumbered survivors, so the checked step moved (or fell off the end)
/// and the checklist silently disagreed with the model — which also decides
/// whether the run keeps going, since an open plan holds the loop.
#[test]
fn completed_indices_are_positions_in_the_sent_array() {
// sent: 0="a" 1=blank 2="b" 3=blank 4="c"; the model checked 2 and 4.
// Blanks sit on BOTH sides of a checked index here on purpose.
let p = wire(&["a", "", "b", " ", "c"], &[2, 4]);
assert_eq!(
p.steps
.iter()
.map(|s| (s.text.as_str(), s.done))
.collect::<Vec<_>>(),
vec![("a", false), ("b", true), ("c", true)],
);
assert_eq!(p.progress(), (2, 3));
assert_eq!(p.current(), Some("a"));
// An index landing ON a dropped blank checks nothing — the step the
// model marked did not survive, and no neighbour inherits its tick.
assert_eq!(wire(&["a", "", "b"], &[1]).progress(), (0, 2));
// Truncation still counts SENT positions: a leading blank must shift
// neither the cap nor the indices ("s0" is sent index 1).
let labels: Vec<String> = (0..MAX_STEPS + 3).map(|i| format!("s{i}")).collect();
let mut many: Vec<&str> = vec![""];
many.extend(labels.iter().map(|s| s.as_str()));
let p = wire(&many, &[1]);
assert_eq!(p.steps.len(), MAX_STEPS);
assert_eq!(p.steps[0].text, "s0");
assert!(p.steps[0].done, "sent index 1 is the first surviving step");
// No-blank plans are unchanged — sent positions ARE survivor positions.
let p = wire(&["a", "b", "c"], &[0, 2]);
assert_eq!(p.progress(), (2, 3));
assert_eq!(p.current(), Some("b"));
}
#[test]
fn blank_steps_drop_and_text_trims() {
let p = wire(&[" keep ", " ", ""], &[]);
assert_eq!(p.steps.len(), 1);
assert_eq!(p.steps[0].text, "keep");
}
#[test]
fn steps_and_length_are_capped() {
let many: Vec<&str> = vec!["step"; MAX_STEPS + 8];
assert_eq!(wire(&many, &[]).steps.len(), MAX_STEPS);
let long = "x".repeat(MAX_STEP_LEN + 50);
let p = wire(&[&long], &[]);
assert_eq!(p.steps[0].text.chars().count(), MAX_STEP_LEN);
}
/// A dropped/garbled update must not panic or mark the wrong step.
#[test]
fn out_of_range_completed_indices_are_ignored() {
let p = wire(&["a", "b"], &[5, -1, 1]);
assert_eq!(p.progress(), (1, 2));
assert!(p.steps[1].done);
assert!(!p.steps[0].done);
}
#[test]
fn truncate_is_char_safe_on_multibyte() {
let p = wire(&[&"é".repeat(MAX_STEP_LEN + 10)], &[]);
assert_eq!(p.steps[0].text.chars().count(), MAX_STEP_LEN);
}
}