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
//! #2129: two defects that made a reaction abort the turn and switch model.
//!
//! 1. Channel directives reached the text-similarity guard. `<<react:👀>>` and
//! `<<react:👍>>` are different actions, but the normalizer kept letters
//! only, so both reduced to the single word `react`. The equality shortcut
//! in `near_duplicate` runs ahead of the short-text guard, so three
//! DIFFERENT reactions read as three near-duplicates, tripped the
//! announcement ring, and the second trip aborted the turn. The abort is
//! provider-attributable, so it walked the fallback chain: a session lost
//! its chosen model for reacting.
//! 2. `OutgoingTextRing::nudged` was a per-session bool that was never cleared,
//! so once a session had been nudged, every later trip in any later turn
//! aborted. It is now a counter capped at [`MAX_TEXT_LOOP_NUDGES`], reset by
//! a turn that did not trip, per the owner's rule: nudge, retry five times,
//! then fall back.
//!
//! Fixtures are synthetic and carry no user identifiers.
use crate::brain::agent::service::announcement_loop::{
MAX_TEXT_LOOP_NUDGES, OutgoingTextRing, TextLoopAction, near_duplicate,
};
const EYES: &str = "<<react:👀>>";
const THUMB: &str = "<<react:👍>>";
const OK: &str = "<<react:👌>>";
const ANNOUNCEMENT: &str = "Sending the zip to this thread now";
// ---- defect 1: directives never reach the similarity guard ----
#[test]
fn different_reactions_are_not_near_duplicates() {
// The exact pair from the 2026-10-18 log, which decided an abort.
assert!(!near_duplicate(THUMB, EYES));
assert!(!near_duplicate(OK, THUMB));
assert!(!near_duplicate(EYES, EYES));
}
#[test]
fn two_different_attachments_are_not_duplicates() {
// Same class, different payload: two image markers must not collide just
// because they share the directive name.
assert!(!near_duplicate("<<IMG:/tmp/a.png>>", "<<IMG:/tmp/b.png>>"));
}
#[test]
fn a_directive_around_prose_does_not_change_the_comparison() {
// The prose is what the guard judges; the marker is channel plumbing.
assert!(near_duplicate(
"On it, sending the zip now <<react:👀>>",
"On it, sending the zip now <<react:👍>>"
));
assert!(!near_duplicate(
"On it, sending the zip now <<react:👀>>",
"Checking the failing test first <<react:👍>>"
));
}
#[test]
fn three_different_reactions_never_trip_the_ring() {
// The logged sequence that aborted the turn: 👌, 👍, 👀 in one session.
let mut ring = OutgoingTextRing::default();
for reaction in [OK, THUMB, EYES] {
assert_eq!(
ring.record_and_check(reaction),
TextLoopAction::Continue,
"a distinct reaction tripped the guard: {reaction}"
);
}
}
#[test]
fn a_directive_only_text_is_recorded_as_a_sentinel_and_never_trips() {
// It must still occupy a slot: `last_recorded` is the dedupe key for the
// final-text check, and leaving a stale older entry there would let the
// same text be counted twice.
let mut ring = OutgoingTextRing::default();
for _ in 0..6 {
assert_eq!(ring.record_and_check(THUMB), TextLoopAction::Continue);
}
assert_eq!(ring.last_recorded(), Some(""));
}
#[test]
fn prose_without_directives_is_untouched_by_the_stripper() {
// The stripper is shared with the phantom gate, so it must only remove a
// real `<<...>>` marker and leave ordinary text alone.
use crate::brain::agent::service::phantom::strip_inline_directives;
assert_eq!(
strip_inline_directives("On it, sending the zip now"),
"On it, sending the zip now"
);
assert_eq!(
strip_inline_directives("On it <<react:👀>>").trim(),
"On it"
);
}
// ---- defect 2: five nudges, then the fallback ----
/// Feed near-identical announcements until the ring trips, and return the
/// action that fired.
///
/// Stops at the first trip: an `Abort` ends the turn in the real code (the
/// caller returns `Err` and stops recording), so continuing to feed after one
/// would test a sequence production can never produce.
fn trip(ring: &mut OutgoingTextRing) -> TextLoopAction {
for _ in 0..3 {
match ring.record_and_check(ANNOUNCEMENT) {
TextLoopAction::Continue => {}
tripped => return tripped,
}
}
TextLoopAction::Continue
}
#[test]
fn the_repeat_fixture_still_reaches_nudge_then_abort_on_the_sixth_nudged_turn() {
// #957 must keep working: a model that ignores every nudge is still cut
// off. The owner's rule is nudge, retry five times, then fall back.
let mut ring = OutgoingTextRing::default();
for turn in 1..=MAX_TEXT_LOOP_NUDGES {
assert_eq!(
trip(&mut ring),
TextLoopAction::Nudge,
"turn {turn} must still nudge, not abort"
);
ring.end_turn(); // the turn tripped, so the count survives
}
assert_eq!(
trip(&mut ring),
TextLoopAction::Abort,
"the sixth nudged turn must reach the fallback path"
);
}
#[test]
fn one_turn_cannot_spend_the_whole_retry_budget() {
// The nudge is a note for the NEXT turn. Ten repeats inside one turn are
// still one retry, otherwise a single noisy turn reaches the provider
// switch before the model has read a single nudge (#2129).
let mut ring = OutgoingTextRing::default();
for _ in 0..10 {
assert_eq!(trip(&mut ring), TextLoopAction::Nudge);
}
ring.end_turn();
// Exactly one retry was spent, so four nudged turns remain.
for turn in 2..=MAX_TEXT_LOOP_NUDGES {
assert_eq!(trip(&mut ring), TextLoopAction::Nudge, "turn {turn}");
ring.end_turn();
}
assert_eq!(trip(&mut ring), TextLoopAction::Abort);
}
#[test]
fn the_abort_releases_the_budget_for_the_next_turn() {
// An abort ends the turn before the turn-closing hook runs, so the abort
// itself has to re-arm the guard. Without that, every later turn in the
// session aborted on its first trip: the #2129 defect one step later.
let mut ring = OutgoingTextRing::default();
for _ in 0..MAX_TEXT_LOOP_NUDGES {
assert_eq!(trip(&mut ring), TextLoopAction::Nudge);
ring.end_turn();
}
assert_eq!(trip(&mut ring), TextLoopAction::Abort);
assert_eq!(
trip(&mut ring),
TextLoopAction::Nudge,
"the turn after an abort must start with a fresh budget"
);
}
#[test]
fn a_clean_turn_clears_the_nudge_count() {
// The reported defect: one trip armed Abort for the rest of the process,
// so an unrelated trip days later killed a turn and switched provider.
let mut ring = OutgoingTextRing::default();
assert_eq!(trip(&mut ring), TextLoopAction::Nudge);
ring.end_turn(); // this turn tripped, so the count survives
// A turn that does not trip: the loop stopped, so the nudge was honoured.
assert_eq!(
ring.record_and_check("Deployed the frontend to staging, all checks green"),
TextLoopAction::Continue
);
ring.end_turn();
// The next unrelated trip must nudge again, not abort.
assert_eq!(
trip(&mut ring),
TextLoopAction::Nudge,
"the nudge count never reset, so this trip aborted the turn"
);
}
#[test]
fn consecutive_tripping_turns_still_escalate_to_abort() {
// No clean turn between them, so the count accumulates even across turns.
let mut ring = OutgoingTextRing::default();
for _ in 0..MAX_TEXT_LOOP_NUDGES {
assert_eq!(trip(&mut ring), TextLoopAction::Nudge);
ring.end_turn();
}
assert_eq!(trip(&mut ring), TextLoopAction::Abort);
}
#[test]
fn a_reaction_after_a_nudge_does_not_abort() {
// End-to-end shape of the field report: the session is nudged, then the
// model replies with a different reaction. Nothing may abort.
let mut ring = OutgoingTextRing::default();
assert_eq!(trip(&mut ring), TextLoopAction::Nudge);
ring.end_turn();
for reaction in [OK, THUMB, EYES] {
assert_ne!(
ring.record_and_check(reaction),
TextLoopAction::Abort,
"a distinct reaction aborted the turn after a nudge: {reaction}"
);
}
}
// ---- #1397: the same normalizer path must still tell commands apart ----
#[test]
fn counter_incremented_commands_stay_apart() {
// #1397: the near-match signature erased the digits that distinguished one
// legitimate command from another. The directive work must not undo that.
assert!(near_duplicate(
"Отправляю 1 подтверждение",
"Отправляю 2 подтверждение"
));
assert!(!near_duplicate("git push origin main", "cargo test --lib"));
}