Cut off a streamed reply that falls into a repetition loop
After a tool call, a model could drift into thousands of words from a tiny vocabulary with no tool call and no end, and the junk went into history. consume_stream now feeds the visible text to a RepetitionGuard. When 200 consecutive words hold at most 40 distinct ones (fenced code excepted), it stops reading, truncates the text to where the loop began, tells the user, and finishes with FinishReason::Repetition. complete retries once with a note, the way it already does for an unparseable inline tool call. Fixes #123 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Oct 9, 2026 at 13:19 UTC
19cd442f74faf729a121dee014161d6e28eec539
5 files changed
+427
-20
.agents/AGENTS.md
+6
@@ -159,6 +159,12 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
159
never touched, and an opening tag only counts at the start of a line, since prose writes
160
placeholders like `<repo_url>` the same way. It has to run after the inline-call scanner,
161
because `<tool_call>` matches its underscore rule.
162
+ `src/repetition.rs` guards the same stream against a reply that degenerates into a loop over a
163
+ tiny vocabulary (issue #123): a window of 200 words with at most 40 distinct ones, fenced code
164
+ excepted, makes `consume_stream` stop reading, truncate the text to where the loop began (so
165
+ history never holds the junk), tell the user, and finish with `FinishReason::Repetition`
166
+ (`EndTurn` in ACP). `complete` then retries once with `REPETITION_RETRY`, like the malformed
167
+ tool call retry. Only the streaming path is guarded; `consume_json` and `LocalBackend` are not.
168
Image attachments: ACP fixes `promptCapabilities.image` for the whole connection, while the
169
model can change on any turn, so the capability is always advertised and the decision is made
170
per prompt. `InferenceBackend::accepts_images` answers for the active model (on-device: no;
CHANGELOG.md
+7
@@ -13,6 +13,13 @@
13
14
### Fixed
15
16
+- **A reply that degenerates into a repetition loop is cut off.** After a tool
17
+ call, a model could fall into thousands of words from a tiny vocabulary
18
+ (`find: list: check: get: …`) with no tool call and no end, and the junk went
19
+ into history for the next turn. siGit Code now watches a streamed reply for a
20
+ window of 200 words with very few distinct ones (code blocks excepted), stops
21
+ reading, keeps only the text before the loop, tells you it stopped, and asks
22
+ the model once to make its call or answer briefly (#123).
23
- **A linked selection written as `L10-L20` or `L10` reads just those lines.**
24
siGit Code read the line range of a `resource_link` only as `L10:20` or
25
`L10-20`, so `L10-L20` (the GitHub style) and a single line `L10` sent the
src/backend.rs
+185
-20
@@ -75,6 +75,9 @@ pub enum FinishReason {
75
Length,
76
/// The endpoint withheld the reply instead of answering.
77
ContentFilter,
78
+ /// The harness cut the reply off because it had degenerated into
79
+ /// repetition (see `repetition`).
80
+ Repetition,
81
}
82
83
impl FinishReason {
@@ -1017,16 +1020,26 @@ impl OpenAiBackend {
1020
sink: Option<&TokenSink>,
1021
) -> Result<TurnResult, BackendError> {
1022
let (result, malformed) = self.request(tools, allow_tool_calls, sink).await?;
1020
- if malformed == 0 || !allow_tool_calls || !result.tool_calls.is_empty() {
1023
+ let retry_note = if !allow_tool_calls || !result.tool_calls.is_empty() {
1024
+ None
1025
+ } else if malformed > 0 {
1026
+ log::warn!(
1027
+ "dropped {malformed} unparseable inline tool call(s); asking the model to retry"
1028
+ );
1029
+ Some(MALFORMED_TOOL_CALL_RETRY)
1030
+ } else if result.finish == FinishReason::Repetition {
1031
+ log::warn!("reply was cut off as a repetition loop; asking the model to retry");
1032
+ Some(REPETITION_RETRY)
1033
+ } else {
1034
+ None
1035
+ };
1036
+ let Some(retry_note) = retry_note else {
1037
return Ok(result);
1022
- }
1038
+ };
1039
1024
- log::warn!(
1025
- "dropped {malformed} unparseable inline tool call(s); asking the model to retry"
1026
- );
1040
self.history.lock().await.push(serde_json::json!({
1041
"role": "user",
1029
- "content": MALFORMED_TOOL_CALL_RETRY,
1042
+ "content": retry_note,
1043
}));
1044
if let Some(sink) = sink
1045
&& !result.text.trim().is_empty()
@@ -1380,6 +1393,9 @@ impl OpenAiBackend {
1393
let mut markup = crate::harness_markup::MarkupFilter::new();
1394
let mut recovered: Vec<ToolCall> = Vec::new();
1395
let mut malformed = 0usize;
1396
+ // Stops a reply that falls into a repetition loop. See `repetition`.
1397
+ let mut guard = crate::repetition::RepetitionGuard::new();
1398
+ let mut degenerate = false;
1399
1400
while let Some(item) = stream.next().await {
1401
let bytes = item.map_err(|error| format!("stream read error: {error}"))?;
@@ -1448,11 +1464,16 @@ impl OpenAiBackend {
1464
continue;
1465
}
1466
text.push_str(&chunk);
1467
+ let looping = guard.push(&chunk);
1468
if sink.send(TokenChunk::Visible(chunk)).is_err() {
1469
// Consumer dropped (turn cancelled).
1470
cancelled = true;
1471
break;
1472
}
1473
+ if looping {
1474
+ degenerate = true;
1475
+ break;
1476
+ }
1477
}
1478
crate::inline_tool_calls::ScanEvent::ToolCall(call) => {
1479
if allow_tool_calls {
@@ -1473,7 +1494,7 @@ impl OpenAiBackend {
1494
}
1495
}
1496
}
1476
- if cancelled {
1497
+ if cancelled || degenerate {
1498
done = true;
1499
break;
1500
}
@@ -1507,20 +1528,35 @@ impl OpenAiBackend {
1528
// tool-call block that never closed is dropped like any other
1529
// unparseable one.
1530
let mut leftover = String::new();
1510
- match scanner.finish() {
1511
- Some(crate::inline_tool_calls::ScanEvent::Text(rest)) => {
1512
- leftover = markup.push(&rest);
1531
+ if degenerate {
1532
+ // Dropping the stream below closes the connection, so the endpoint
1533
+ // stops generating. Nothing held back is worth flushing.
1534
+ let keep = guard.loop_start().unwrap_or(text.len()).min(text.len());
1535
+ log::warn!(
1536
+ "reply degenerated into repetition; cut {} of {} bytes",
1537
+ text.len() - keep,
1538
+ text.len()
1539
+ );
1540
+ text.truncate(keep);
1541
+ text.truncate(text.trim_end().len());
1542
+ finish = FinishReason::Repetition;
1543
+ let _ = sink.send(TokenChunk::Visible(REPETITION_NOTICE.to_string()));
1544
+ } else {
1545
+ match scanner.finish() {
1546
+ Some(crate::inline_tool_calls::ScanEvent::Text(rest)) => {
1547
+ leftover = markup.push(&rest);
1548
+ }
1549
+ Some(crate::inline_tool_calls::ScanEvent::Malformed(block)) => {
1550
+ log_malformed_block(&block);
1551
+ malformed += 1;
1552
+ }
1553
+ Some(crate::inline_tool_calls::ScanEvent::ToolCall(_)) | None => {}
1554
}
1514
- Some(crate::inline_tool_calls::ScanEvent::Malformed(block)) => {
1515
- log_malformed_block(&block);
1516
- malformed += 1;
1555
+ leftover.push_str(&markup.finish());
1556
+ if !leftover.is_empty() {
1557
+ text.push_str(&leftover);
1558
+ let _ = sink.send(TokenChunk::Visible(leftover));
1559
}
1518
- Some(crate::inline_tool_calls::ScanEvent::ToolCall(_)) | None => {}
1519
- }
1520
- leftover.push_str(&markup.finish());
1521
- if !leftover.is_empty() {
1522
- text.push_str(&leftover);
1523
- let _ = sink.send(TokenChunk::Visible(leftover));
1560
}
1561
1562
let mut tool_calls: Vec<ToolCall> = tool_accum
@@ -1553,7 +1589,7 @@ impl OpenAiBackend {
1589
}
1590
1591
// Record the assistant turn so later tool results have context.
1556
- if malformed > 0 && tool_calls.is_empty() {
1592
+ if (malformed > 0 || degenerate) && tool_calls.is_empty() {
1593
self.push_reply_without_calls(&text).await;
1594
} else {
1595
self.history
@@ -1579,6 +1615,16 @@ const MALFORMED_TOOL_CALL_RETRY: &str = "[siGit Code] Your last reply tried to c
1615
not see it. Do not write tool calls or tool results as text. Make the call again using the \
1616
tool-calling interface, or answer in plain prose if no tool is needed.";
1617
1618
+/// Sent as a user turn after a reply was cut off as a repetition loop.
1619
+const REPETITION_RETRY: &str = "[siGit Code] Your last reply started repeating the same few \
1620
+ words and was cut off. Do not continue it. Make the next tool call you need, or answer \
1621
+ briefly in plain prose.";
1622
+
1623
+/// Shown to the user, who has already watched the loop stream. Not kept in
1624
+/// history.
1625
+const REPETITION_NOTICE: &str =
1626
+ "\n\n[siGit Code stopped this reply because the model started repeating itself.]";
1627
+
1628
fn log_malformed_block(block: &str) {
1629
log::warn!(
1630
"dropped an unparseable inline tool call ({} chars): {}",
@@ -2834,6 +2880,125 @@ mod tests {
2880
(addr, receiver)
2881
}
2882
2883
+ /// Scripted SSE endpoint: one response per connection, in order. The
2884
+ /// receiver yields each request body.
2885
+ fn spawn_sse_sequence_stub(
2886
+ replies: Vec<Vec<String>>,
2887
+ ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
2888
+ use std::io::{Read, Write};
2889
+
2890
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2891
+ let addr = listener.local_addr().unwrap();
2892
+ let (sender, receiver) = std::sync::mpsc::channel();
2893
+ std::thread::spawn(move || {
2894
+ for deltas in replies {
2895
+ let (mut stream, _) = listener.accept().unwrap();
2896
+ let mut request = Vec::new();
2897
+ let mut buf = [0u8; 4096];
2898
+ loop {
2899
+ let n = stream.read(&mut buf).unwrap_or(0);
2900
+ if n == 0 {
2901
+ break;
2902
+ }
2903
+ request.extend_from_slice(&buf[..n]);
2904
+ if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") {
2905
+ let headers = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase();
2906
+ let length = headers
2907
+ .lines()
2908
+ .find_map(|l| l.strip_prefix("content-length:")?.trim().parse().ok())
2909
+ .unwrap_or(0usize);
2910
+ if request.len() >= end + 4 + length {
2911
+ let _ = sender
2912
+ .send(String::from_utf8_lossy(&request[end + 4..]).into_owned());
2913
+ break;
2914
+ }
2915
+ }
2916
+ }
2917
+ let mut body = String::new();
2918
+ for delta in deltas {
2919
+ let frame = serde_json::json!({
2920
+ "choices": [{ "delta": { "content": delta } }]
2921
+ });
2922
+ body.push_str(&format!("data: {frame}\n\n"));
2923
+ }
2924
+ body.push_str("data: [DONE]\n\n");
2925
+ let response = format!(
2926
+ "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
2927
+ content-length: {}\r\nconnection: close\r\n\r\n{body}",
2928
+ body.len()
2929
+ );
2930
+ let _ = stream.write_all(response.as_bytes());
2931
+ }
2932
+ });
2933
+ (addr, receiver)
2934
+ }
2935
+
2936
+ /// Issue #123: a streamed reply that degenerates into a repetition loop is
2937
+ /// cut off, history keeps only what came before the loop, and the model
2938
+ /// gets one retry.
2939
+ #[tokio::test]
2940
+ async fn a_repetition_loop_is_cut_and_retried_once() {
2941
+ let intro = "Let me look at the commits. ";
2942
+ let looped: Vec<String> = std::iter::once(intro.to_string())
2943
+ .chain(
2944
+ crate::repetition::issue_loop_text()
2945
+ .as_bytes()
2946
+ .chunks(40)
2947
+ .map(|c| String::from_utf8_lossy(c).into_owned()),
2948
+ )
2949
+ .collect();
2950
+ let (addr, requests) =
2951
+ spawn_sse_sequence_stub(vec![looped, vec!["Here is the answer.".to_string()]]);
2952
+ let backend =
2953
+ OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
2954
+ let (sink, mut tokens) = tokio::sync::mpsc::unbounded_channel();
2955
+ let tools = vec![ToolSpec {
2956
+ name: "run_command".to_string(),
2957
+ description: "Run a command".to_string(),
2958
+ parameters_schema: r#"{"type":"object","properties":{}}"#.to_string(),
2959
+ }];
2960
+
2961
+ let result = backend
2962
+ .send_message_with_tools("what changed", &tools, Some(&sink))
2963
+ .await
2964
+ .unwrap();
2965
+
2966
+ assert_eq!(result.finish, FinishReason::Complete);
2967
+ assert!(result.text.starts_with(intro.trim_end()), "{}", result.text);
2968
+ assert!(result.text.ends_with("Here is the answer."));
2969
+ assert!(
2970
+ result.text.len() < 600,
2971
+ "junk kept: {} bytes",
2972
+ result.text.len()
2973
+ );
2974
+
2975
+ let first = requests.recv().unwrap();
2976
+ assert!(!first.contains(REPETITION_RETRY));
2977
+ let second = requests.recv().unwrap();
2978
+ assert!(second.contains(REPETITION_RETRY));
2979
+
2980
+ let history = backend.history_snapshot().await;
2981
+ let kept = history
2982
+ .iter()
2983
+ .find(|m| m["role"] == "assistant")
2984
+ .and_then(|m| m["content"].as_str())
2985
+ .unwrap();
2986
+ assert!(
2987
+ kept.len() < 600,
2988
+ "history kept the loop: {} bytes",
2989
+ kept.len()
2990
+ );
2991
+
2992
+ drop(sink);
2993
+ let mut shown = String::new();
2994
+ while let Ok(chunk) = tokens.try_recv() {
2995
+ if let TokenChunk::Visible(text) = chunk {
2996
+ shown.push_str(&text);
2997
+ }
2998
+ }
2999
+ assert!(shown.contains("stopped this reply"), "{shown}");
3000
+ }
3001
+
3002
#[test]
3003
fn finish_reasons_map_from_the_wire() {
3004
for reason in [None, Some("stop"), Some("tool_calls"), Some("unheard-of")] {
src/main.rs
+8
@@ -46,6 +46,7 @@ mod mcp;
46
mod models;
47
mod permissions;
48
mod provider;
49
+mod repetition;
50
mod session_store;
51
mod settings;
52
mod setup;
@@ -309,6 +310,8 @@ fn stop_reason_for(round_cap_reached: bool, finish: backend::FinishReason) -> St
310
backend::FinishReason::Complete => StopReason::EndTurn,
311
backend::FinishReason::Length => StopReason::MaxTokens,
312
backend::FinishReason::ContentFilter => StopReason::Refusal,
313
+ // Cut by the agent, not the token limit; no ACP reason fits better.
314
+ backend::FinishReason::Repetition => StopReason::EndTurn,
315
}
316
}
317
@@ -6318,11 +6321,16 @@ mod tests {
6321
stop_reason_for(false, FinishReason::ContentFilter),
6322
StopReason::Refusal
6323
);
6324
+ assert_eq!(
6325
+ stop_reason_for(false, FinishReason::Repetition),
6326
+ StopReason::EndTurn
6327
+ );
6328
// The cap wins: the last round was a forced reply either way.
6329
for finish in [
6330
FinishReason::Complete,
6331
FinishReason::Length,
6332
FinishReason::ContentFilter,
6333
+ FinishReason::Repetition,
6334
] {
6335
assert_eq!(stop_reason_for(true, finish), StopReason::MaxTurnRequests);
6336
}
src/repetition.rs
new
+221
@@ -0,0 +1,221 @@
1
+//! Spots a streamed reply that has degenerated into a repetition loop.
2
+//!
3
+//! A model that falls into degenerate decoding keeps producing words from a
4
+//! tiny vocabulary and never emits a tool call or a stop (issue #123: thousands
5
+//! of words of `find: list: check: get: …`). It is not an exact repeat, so the
6
+//! check looks at how few distinct words a recent window holds, not at literal
7
+//! repeated strings.
8
+
9
+use std::collections::{HashMap, VecDeque};
10
+
11
+/// Words in the sliding window.
12
+const WINDOW: usize = 200;
13
+/// The window is re-checked after this many new words, so the cost stays flat
14
+/// on a long reply.
15
+const CHECK_EVERY: usize = 25;
16
+/// A full window with at most this many distinct words is degenerate. Ordinary
17
+/// prose of 200 words holds roughly 90 to 120 distinct ones; the loop in the
18
+/// issue holds about 30 and an exact repeat far fewer, so there is room on both
19
+/// sides.
20
+const MAX_DISTINCT: usize = 40;
21
+
22
+/// Fed the same visible text the user is shown, in order. Words inside fenced
23
+/// code blocks are skipped: code, tables and data are legitimately repetitive.
24
+#[derive(Default)]
25
+pub struct RepetitionGuard {
26
+ /// Bytes fed so far; the offset of the next char in the caller's text.
27
+ offset: usize,
28
+ window: VecDeque<(String, usize)>,
29
+ counts: HashMap<String, usize>,
30
+ since_check: usize,
31
+ word: String,
32
+ word_start: usize,
33
+ /// The first up-to-3 non-blank chars of the current line.
34
+ line_head: String,
35
+ fence_line: bool,
36
+ in_code: bool,
37
+ tripped_at: Option<usize>,
38
+}
39
+
40
+impl RepetitionGuard {
41
+ pub fn new() -> Self {
42
+ Self::default()
43
+ }
44
+
45
+ /// Feed the next piece of text. Returns true once the reply has tripped,
46
+ /// and keeps returning true after that.
47
+ pub fn push(&mut self, text: &str) -> bool {
48
+ for (i, c) in text.char_indices() {
49
+ if self.tripped_at.is_some() {
50
+ break;
51
+ }
52
+ self.feed(self.offset + i, c);
53
+ }
54
+ self.offset += text.len();
55
+ self.tripped_at.is_some()
56
+ }
57
+
58
+ /// Byte offset in the fed text where the offending window began. The text
59
+ /// before it is the reply worth keeping.
60
+ pub fn loop_start(&self) -> Option<usize> {
61
+ self.tripped_at
62
+ }
63
+
64
+ fn feed(&mut self, at: usize, c: char) {
65
+ if c == '\n' {
66
+ self.end_word();
67
+ self.line_head.clear();
68
+ self.fence_line = false;
69
+ return;
70
+ }
71
+ if self.line_head.chars().count() < 3 && !(c.is_whitespace() && self.line_head.is_empty()) {
72
+ self.line_head.push(c);
73
+ if self.line_head == "```" {
74
+ self.in_code = !self.in_code;
75
+ self.fence_line = true;
76
+ }
77
+ }
78
+ if self.in_code || self.fence_line || !c.is_alphanumeric() {
79
+ self.end_word();
80
+ return;
81
+ }
82
+ if self.word.is_empty() {
83
+ self.word_start = at;
84
+ }
85
+ self.word.extend(c.to_lowercase());
86
+ }
87
+
88
+ /// Where the loop began inside the window. The window can hold a few words
89
+ /// of real reply ahead of it, so walk back from the end while words still
90
+ /// belong to the vocabulary of the window's second half.
91
+ fn loop_origin(&self) -> usize {
92
+ let vocab: std::collections::HashSet<&str> = self
93
+ .window
94
+ .iter()
95
+ .skip(WINDOW / 2)
96
+ .map(|(word, _)| word.as_str())
97
+ .collect();
98
+ let mut origin = self.window.front().map_or(0, |(_, start)| *start);
99
+ for (word, start) in self.window.iter().rev() {
100
+ if !vocab.contains(word.as_str()) {
101
+ break;
102
+ }
103
+ origin = *start;
104
+ }
105
+ origin
106
+ }
107
+
108
+ fn end_word(&mut self) {
109
+ if self.word.is_empty() {
110
+ return;
111
+ }
112
+ let word = std::mem::take(&mut self.word);
113
+ *self.counts.entry(word.clone()).or_default() += 1;
114
+ self.window.push_back((word, self.word_start));
115
+ if self.window.len() > WINDOW
116
+ && let Some((old, _)) = self.window.pop_front()
117
+ && let Some(count) = self.counts.get_mut(&old)
118
+ {
119
+ *count -= 1;
120
+ if *count == 0 {
121
+ self.counts.remove(&old);
122
+ }
123
+ }
124
+ self.since_check += 1;
125
+ if self.window.len() == WINDOW
126
+ && self.since_check >= CHECK_EVERY
127
+ && self.counts.len() <= MAX_DISTINCT
128
+ {
129
+ self.tripped_at = Some(self.loop_origin());
130
+ }
131
+ if self.since_check >= CHECK_EVERY {
132
+ self.since_check = 0;
133
+ }
134
+ }
135
+}
136
+
137
+/// The shape of the loop in issue #123: a random walk over about 30 words.
138
+#[cfg(test)]
139
+pub(crate) fn issue_loop_text() -> String {
140
+ let vocab = [
141
+ "find:", "list:", "check:", "get:", "find", "them:", "list", "commits", "with", "that",
142
+ "key:", "show:", "read:", "search:", "open:", "match:", "run:", "see:", "look:", "take:",
143
+ "grab:", "pull:", "fetch:", "scan:", "test:", "view:", "load:", "parse:", "walk:", "dump:",
144
+ ];
145
+ let mut state = 7u32;
146
+ let mut out = String::new();
147
+ for _ in 0..1500 {
148
+ state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
149
+ out.push_str(vocab[(state >> 16) as usize % vocab.len()]);
150
+ out.push(' ');
151
+ }
152
+ out
153
+}
154
+
155
+#[cfg(test)]
156
+mod tests {
157
+ use super::*;
158
+
159
+ fn prose() -> String {
160
+ // Distinct words throughout, like a real paragraph.
161
+ (0..400)
162
+ .map(|n| format!("word{n}"))
163
+ .collect::<Vec<_>>()
164
+ .join(" ")
165
+ }
166
+
167
+ fn feed_in_chunks(guard: &mut RepetitionGuard, text: &str) -> bool {
168
+ let mut tripped = false;
169
+ for piece in text.as_bytes().chunks(7) {
170
+ tripped = guard.push(&String::from_utf8_lossy(piece));
171
+ }
172
+ tripped
173
+ }
174
+
175
+ #[test]
176
+ fn the_issue_loop_trips() {
177
+ let mut guard = RepetitionGuard::new();
178
+ assert!(feed_in_chunks(&mut guard, &issue_loop_text()));
179
+ }
180
+
181
+ #[test]
182
+ fn a_short_exact_loop_trips_and_reports_where_it_began() {
183
+ let intro = "Let me check the history first. ";
184
+ let text = format!("{intro}{}", "a b a b ".repeat(200));
185
+ let mut guard = RepetitionGuard::new();
186
+ assert!(guard.push(&text));
187
+ let start = guard.loop_start().unwrap();
188
+ assert!(start >= intro.len(), "cut {start} is inside the intro");
189
+ assert!(text.is_char_boundary(start));
190
+ }
191
+
192
+ #[test]
193
+ fn ordinary_prose_does_not_trip() {
194
+ let mut guard = RepetitionGuard::new();
195
+ assert!(!feed_in_chunks(&mut guard, &prose()));
196
+ assert_eq!(guard.loop_start(), None);
197
+ }
198
+
199
+ #[test]
200
+ fn a_fenced_block_of_repeated_lines_does_not_trip() {
201
+ let text = format!("Here:\n```\n{}```\nDone.\n", "foo bar\n".repeat(400));
202
+ let mut guard = RepetitionGuard::new();
203
+ assert!(!feed_in_chunks(&mut guard, &text));
204
+ }
205
+
206
+ #[test]
207
+ fn a_list_of_similar_bullets_does_not_trip() {
208
+ let text: String = (0..60)
209
+ .map(|n| format!("- Update the handler for route{n} so it returns item{n} quickly\n"))
210
+ .collect();
211
+ let mut guard = RepetitionGuard::new();
212
+ assert!(!feed_in_chunks(&mut guard, &text));
213
+ }
214
+
215
+ #[test]
216
+ fn text_after_a_closed_fence_counts_again() {
217
+ let text = format!("```\nx\n```\n{}", "a b a b ".repeat(200));
218
+ let mut guard = RepetitionGuard::new();
219
+ assert!(guard.push(&text));
220
+ }
221
+}