Skip to content

Commit 6bdae1c

Browse files
committed
Merge branch 'main' into cl-6907-tui-fires-refreshcodexinstructions-un-awaited-request-prefix
2 parents 0b2ce81 + 1a903fc commit 6bdae1c

7 files changed

Lines changed: 353 additions & 40 deletions

File tree

src/subagent/repetition.test.ts

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ import { describe, expect, test } from "bun:test";
33
import { appendCycleText, CYCLE_TEXT_CAP_CHARS } from "../session/stream-journal.js";
44
import {
55
detectRepetition,
6+
DEFAULT_CONTENTLESS_GROWTH_CONFIG,
67
DEFAULT_REPETITION_CONFIG,
8+
DEFAULT_TEXT_FOLDED_REPETITION_CONFIG,
79
DEFAULT_THINKING_REPETITION_CONFIG,
10+
INITIAL_CONTENTLESS_GROWTH_STATE,
811
REPETITION_CHECK_INTERVAL_CHARS,
12+
trackContentlessGrowth,
13+
type ContentlessGrowthState,
914
} from "./repetition.js";
1015

1116
// A monotonic counter that never repeats verbatim: each pair's numerator and
@@ -21,8 +26,9 @@ const LOOP_SENTENCE =
2126
describe("detectRepetition", () => {
2227
test("flags a looped status sentence with an oscillating counter", () => {
2328
// Counters that flip between values keep the raw text periodic — the
24-
// period just spans one full oscillation (two sentences here).
25-
const iterations = Array.from({ length: 20 }, (_, i) =>
29+
// period just spans one full oscillation (two sentences here), so hitting
30+
// the repeat threshold takes twice as many iterations.
31+
const iterations = Array.from({ length: 40 }, (_, i) =>
2632
LOOP_SENTENCE.replace("0/1.0", `${i % 2}/1.0`),
2733
);
2834
const text = `some earlier legitimate prose about the task. ${iterations.join("")}`;
@@ -73,6 +79,26 @@ describe("detectRepetition", () => {
7379
expect(detectRepetition(looped)).not.toBeNull();
7480
});
7581

82+
test("flags a short-phrase loop (10-char unit, observed live)", () => {
83+
// The second captured incident: "Groaning. " emitted ~1,363 times. The
84+
// old 16-char window floor never saw a 10-char unit.
85+
const text = "Groaning. ".repeat(1300);
86+
const hit = detectRepetition(text);
87+
expect(hit).not.toBeNull();
88+
expect(hit?.window).toBe("Groaning. ");
89+
expect(hit?.repeats).toBeGreaterThanOrEqual(DEFAULT_REPETITION_CONFIG.repeatThreshold);
90+
});
91+
92+
test("does not flag a repeated markdown table separator row", () => {
93+
const row = "| ---------------------- | ---------------------- |\n";
94+
expect(detectRepetition(`| Left | Right |\n${row.repeat(6)}`)).toBeNull();
95+
});
96+
97+
test("does not flag a few identical code lines", () => {
98+
const line = " const result = await fetchData(request, options, context)\n";
99+
expect(detectRepetition(line.repeat(3))).toBeNull();
100+
});
101+
76102
test("returns null for text shorter than one full window set", () => {
77103
expect(detectRepetition("short")).toBeNull();
78104
expect(detectRepetition("")).toBeNull();
@@ -170,12 +196,135 @@ test("flags a loop that injects zero-width spaces between identical windows", ()
170196
// detector misses the loop (observed in live thrash fleets).
171197
const window = "I'll open the remaining source files and implement the activity preview. ";
172198
const zwsp = "\u200B";
173-
const text = (window + zwsp).repeat(12);
199+
const text = (window + zwsp).repeat(20);
174200
const hit = detectRepetition(text);
175201
expect(hit).not.toBeNull();
176202
expect(hit?.repeats).toBeGreaterThanOrEqual(DEFAULT_REPETITION_CONFIG.repeatThreshold);
177203
});
178204

205+
describe("folded text pass (DEFAULT_TEXT_FOLDED_REPETITION_CONFIG)", () => {
206+
// Mirrors run.ts: digit-preserving default first, capped folded pass second.
207+
function detectText(text: string) {
208+
return (
209+
detectRepetition(text) ??
210+
detectRepetition(text, DEFAULT_TEXT_FOLDED_REPETITION_CONFIG, { normalizeDigits: true })
211+
);
212+
}
213+
214+
test("flags an incrementing counter flood in visible text", () => {
215+
// Observed live: "14279 14280 14281…" streamed to inference-error.
216+
const text = Array.from({ length: 500 }, (_, i) => `${14279 + i} `).join("");
217+
expect(detectRepetition(text)).toBeNull();
218+
expect(detectText(text)).not.toBeNull();
219+
});
220+
221+
test("flags an incrementing pair-counter flood", () => {
222+
// Observed live: "5620/5620. 5621/5621. …"
223+
const text = Array.from({ length: 300 }, (_, i) => `${5620 + i}/${5620 + i}. `).join("");
224+
expect(detectText(text)).not.toBeNull();
225+
});
226+
227+
test("flags a repeated-timestamp flood", () => {
228+
// Observed live: "18:22:27." emitted hundreds of times, with drift.
229+
const text = Array.from({ length: 300 }, (_, i) => `18:22:${27 + (i % 30)}. `).join("");
230+
expect(detectText(text)).not.toBeNull();
231+
});
232+
233+
test("flags a zero-percent flood", () => {
234+
// "0% " is a 3-char unit — under the plain 8-char window floor.
235+
const text = "0% ".repeat(200);
236+
expect(detectRepetition(text)).toBeNull();
237+
expect(detectText(text)).not.toBeNull();
238+
});
239+
240+
test("flags fence and brace floods", () => {
241+
expect(detectText("```\n".repeat(100))).not.toBeNull();
242+
expect(detectText("}\n".repeat(200))).not.toBeNull();
243+
});
244+
245+
test("flags an emoji flood (surrogate-pair unit)", () => {
246+
// Observed live: "🤔 " ×~40K chars. The unit is 3 UTF-16 units; the
247+
// code-point reversal must keep the pair intact for it to stay periodic.
248+
const text = "🤔 ".repeat(2000);
249+
expect(detectText(text)).not.toBeNull();
250+
});
251+
252+
test("does not flag a numbered list under the folded text pass", () => {
253+
// Folds to a ~47-char period — refused by maxFoldedPeriodChars.
254+
const items = Array.from(
255+
{ length: 400 },
256+
(_, i) => `${i + 1}. Ran batch ${i + 1} and verified ${i * 3} records migrated\n`,
257+
).join("");
258+
expect(detectText(`Migration progress:\n${items}`)).toBeNull();
259+
});
260+
261+
test("does not flag a digit-varying markdown table under the folded text pass", () => {
262+
const rows = Array.from(
263+
{ length: 40 },
264+
(_, i) => `| 202${i % 10} | ${i * 10} requests | ${i} errors |\n`,
265+
).join("");
266+
expect(detectText(`| Year | Volume | Errors |\n|---|---|---|\n${rows}`)).toBeNull();
267+
});
268+
269+
test("a short user-requested enumeration stays under the folded repeat bar", () => {
270+
// "print 1..40" folds to "0 " ×40 — under repeatThreshold 64.
271+
const text = Array.from({ length: 40 }, (_, i) => `${i + 1} `).join("");
272+
expect(detectText(text)).toBeNull();
273+
});
274+
});
275+
276+
describe("trackContentlessGrowth", () => {
277+
function feed(tokens: readonly string[]): boolean {
278+
let state: ContentlessGrowthState = INITIAL_CONTENTLESS_GROWTH_STATE;
279+
for (const token of tokens) {
280+
const next = trackContentlessGrowth(state, token);
281+
if (next.hit) return true;
282+
state = next.state;
283+
}
284+
return false;
285+
}
286+
287+
test("flags a zero-width flood (ZWNJ/ZWJ walls, observed live)", () => {
288+
// Observed live: 500–53,000 U+200C/U+200D chars per stream.
289+
// detectRepetition strips invisibles before checking, so it must not be
290+
// the only line of defense.
291+
const flood = Array.from({ length: 60 }, () => "‌‍".repeat(32));
292+
expect(detectRepetition(flood.join(""))).toBeNull();
293+
expect(feed(flood)).toBe(true);
294+
});
295+
296+
test("flags a flood even when prefixed by healthy prose", () => {
297+
const tokens = [
298+
"Let me look at the config first. ".repeat(4),
299+
...Array.from({ length: 100 }, () => "‍".repeat(64)),
300+
];
301+
expect(feed(tokens)).toBe(true);
302+
});
303+
304+
test("does not flag ordinary prose or sparse code", () => {
305+
const tokens = Array.from(
306+
{ length: 200 },
307+
(_, i) => ` const value${i} = await compute(input${i});\n\n`,
308+
);
309+
expect(feed(tokens)).toBe(false);
310+
});
311+
312+
test("a visible-rich window re-arms rather than latching", () => {
313+
// Enough visible content inside every window keeps the guard quiet no
314+
// matter how long the stream runs.
315+
const tokens = Array.from(
316+
{ length: 50 },
317+
() => `${"‌".repeat(100)} some genuinely visible sentence with plenty of characters. `,
318+
);
319+
expect(feed(tokens)).toBe(false);
320+
});
321+
322+
test("whitespace does not count as visible content", () => {
323+
const raw = " \n\t".repeat(DEFAULT_CONTENTLESS_GROWTH_CONFIG.rawWindowChars);
324+
expect(feed([raw])).toBe(true);
325+
});
326+
});
327+
179328
describe("appendCycleText", () => {
180329
test("keeps only the tail past the cap", () => {
181330
const text = appendCycleText("a".repeat(10), "b".repeat(10), 15);

src/subagent/repetition.ts

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,18 @@ export interface RepetitionConfig {
2929
maxFoldedPeriodChars?: number;
3030
}
3131

32-
// windowMinChars * repeatThreshold = 128 chars of exactly periodic text —
33-
// far beyond anything legitimate prose or code produces by accident.
32+
// windowMinChars * repeatThreshold = 8 * 16 = 128 chars of exactly periodic
33+
// text — far beyond anything legitimate prose or code produces by accident.
34+
// windowMinChars sits at 8 because live loops repeat units as short as 10
35+
// chars ("Groaning. " emitted ~1,363 times), which a 16-char floor never sees;
36+
// the repeat threshold rises to 16 in compensation so the minimum periodic
37+
// span stays at 128 chars. Structural tics that legitimately repeat ("- item\n"
38+
// normalizes to 7 chars) still fall under the window floor, and longer healthy
39+
// repeats (a 6-row table separator, 3 identical code lines, a repeat(4)
40+
// paragraph) stay far below 16 consecutive repeats.
3441
export const DEFAULT_REPETITION_CONFIG: RepetitionConfig = {
35-
windowMinChars: 16,
36-
repeatThreshold: 8,
42+
windowMinChars: 8,
43+
repeatThreshold: 16,
3744
probeChars: 8192,
3845
};
3946

@@ -65,6 +72,33 @@ export const DEFAULT_THINKING_REPETITION_CONFIG: RepetitionConfig = {
6572
maxFoldedPeriodChars: 16,
6673
};
6774

75+
// Second, folded pass over *text* streams, for the flood shapes the default
76+
// (digit-preserving) config is structurally blind to. Live traces ending as
77+
// inference-error (670K wasted streamed chars) showed: incrementing counters
78+
// ("14279 14280 14281…", "5620/5620. 5621/5621…" — never byte-periodic),
79+
// repeated timestamps with drift ("18:22:27. 18:22:28."), "0% 0% 0%…",
80+
// repeated "```\n" fences and "}\n" braces, and emoji floods ("🤔 " ×~40K
81+
// chars). All fold (or already normalize) to a tiny 2–16 char period.
82+
//
83+
// The safety story for visible text is different from thinking, hence the
84+
// stricter numbers rather than reusing the thinking config:
85+
// - maxFoldedPeriodChars 16 refuses prose-shaped folds exactly as it does for
86+
// thinking: a numbered-list or table row folds to a ~30–50 char period and
87+
// never fires (see the normalize() rationale below).
88+
// - windowMinChars 2 (vs thinking's 4) reaches the shortest observed units:
89+
// "0% " and "} " fold to 2–3 chars, below the thinking floor.
90+
// - repeatThreshold 64 (vs 32): the residual false-positive risk for text is
91+
// a user-requested raw enumeration ("print 1..N"), which folds to "0 " —
92+
// a legit dump of a few dozen numbers stays under 64 consecutive repeats,
93+
// while the observed floods repeat thousands of times. Minimum folded
94+
// periodic span: 2 * 64 = 128 chars.
95+
export const DEFAULT_TEXT_FOLDED_REPETITION_CONFIG: RepetitionConfig = {
96+
windowMinChars: 2,
97+
repeatThreshold: 64,
98+
probeChars: 8192,
99+
maxFoldedPeriodChars: 16,
100+
};
101+
68102
export interface RepetitionHit {
69103
/** The normalized window that repeats. */
70104
window: string;
@@ -85,9 +119,11 @@ export interface RepetitionHit {
85119
// evade the detector. Observed thrash loops used U+200B between repeats.
86120
// `normalizeDigits` opts a caller into folding digit runs to one placeholder,
87121
// which collapses a monotonic counter's varying digits into a repeating unit.
88-
// Reserved for thinking streams (see DEFAULT_THINKING_REPETITION_CONFIG),
89-
// which are never rendered to the user and so carry none of the numbered-list
90-
// / table false-positive risk that keeps text normalization digit-preserving.
122+
// The digit-preserving default protects text streams' numbered lists and
123+
// tables; folded detection runs on them only as a second pass capped to tiny
124+
// periods (DEFAULT_TEXT_FOLDED_REPETITION_CONFIG), and uncapped-in-spirit on
125+
// thinking streams (DEFAULT_THINKING_REPETITION_CONFIG), which are never
126+
// rendered to the user and so carry less false-positive cost.
91127
function normalize(text: string, normalizeDigits: boolean): string {
92128
const stripped = text
93129
.replace(/[\u200B-\u200D\uFEFF\u00AD\u2060\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "")
@@ -121,6 +157,10 @@ export function detectRepetition(
121157
const tail = normalize(text.slice(-config.probeChars), opts.normalizeDigits ?? false);
122158
if (tail.length < config.windowMinChars * config.repeatThreshold) return null;
123159

160+
// Reverse by code point so surrogate pairs survive intact — an emoji flood
161+
// ("🤔 " ×thousands) must stay byte-periodic after reversal. The prefix
162+
// function and window extraction then both count plain UTF-16 units of the
163+
// (pair-preserving) reversed string, so periods and slices stay consistent.
124164
const reversed = [...tail].reverse().join("");
125165
const pi = prefixFunction(reversed);
126166

@@ -140,3 +180,68 @@ export function detectRepetition(
140180
}
141181
return best;
142182
}
183+
184+
/** Tunables for the contentless-growth guard. */
185+
export interface ContentlessGrowthConfig {
186+
/** Raw streamed chars per measurement window. */
187+
rawWindowChars: number;
188+
/** A window with fewer visible chars than this counts as contentless. */
189+
minVisibleChars: number;
190+
}
191+
192+
// detectRepetition can never see a zero-width flood: normalize() strips
193+
// invisibles *before* the periodicity check, so thousands of U+200C/U+200D
194+
// chars (observed live: 500–53,000 per stream) collapse to a short, healthy-
195+
// looking string. This guard watches the inverse signal — raw text keeps
196+
// growing while its visible content does not. The bar: 2048 raw chars with
197+
// fewer than 32 visible. Legitimate sparse output never approaches it — even
198+
// a heavily indented code block or a wide table row carries hundreds of
199+
// visible chars per 2048 raw, and a healthy stream would need 64:1
200+
// invisible-or-whitespace-to-content to trip it.
201+
export const DEFAULT_CONTENTLESS_GROWTH_CONFIG: ContentlessGrowthConfig = {
202+
rawWindowChars: 2048,
203+
minVisibleChars: 32,
204+
};
205+
206+
export interface ContentlessGrowthState {
207+
/** Raw chars accumulated in the current window. */
208+
rawChars: number;
209+
/** Visible (invisible-stripped, whitespace-removed) chars in the window. */
210+
visibleChars: number;
211+
}
212+
213+
export const INITIAL_CONTENTLESS_GROWTH_STATE: ContentlessGrowthState = {
214+
rawChars: 0,
215+
visibleChars: 0,
216+
};
217+
218+
// Whitespace is removed rather than collapsed: a window of pure newlines is
219+
// as contentless as one of pure ZWJ, and counting collapsed runs would let a
220+
// space-interleaved flood (ZWJ, space, ZWJ, space, …) smuggle half its
221+
// length past the epsilon.
222+
function visibleLength(token: string): number {
223+
return normalize(token, false).replace(/ /g, "").length;
224+
}
225+
226+
/**
227+
* Fold one streamed token into the contentless-growth window. Returns the
228+
* next state and whether the just-completed window was contentless: raw text
229+
* grew by a full window while visible content grew less than the epsilon.
230+
* Pure reducer — the caller owns the state across deltas; the window resets
231+
* on completion either way, so one visible-rich window re-arms the guard.
232+
*/
233+
export function trackContentlessGrowth(
234+
state: ContentlessGrowthState,
235+
token: string,
236+
config: ContentlessGrowthConfig = DEFAULT_CONTENTLESS_GROWTH_CONFIG,
237+
): { state: ContentlessGrowthState; hit: boolean } {
238+
const rawChars = state.rawChars + token.length;
239+
const visibleChars = state.visibleChars + visibleLength(token);
240+
if (rawChars < config.rawWindowChars) {
241+
return { state: { rawChars, visibleChars }, hit: false };
242+
}
243+
return {
244+
state: INITIAL_CONTENTLESS_GROWTH_STATE,
245+
hit: visibleChars < config.minVisibleChars,
246+
};
247+
}

0 commit comments

Comments
 (0)