diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 22766723..61773906 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -78,7 +78,7 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/; * Without clustering, a single first-VPA-finds-all approach would discard the entire * conversation after Claude's first render — losing 100KB+ of legitimate scrollback. */ -function stripInkRedrawBloat(buffer: string): string { +export function stripInkRedrawBloat(buffer: string): string { // eslint-disable-next-line no-control-regex const vpaRe = /\x1b\[\d+d/g; const positions: number[] = []; diff --git a/test/strip-ink-redraw-bloat.test.ts b/test/strip-ink-redraw-bloat.test.ts new file mode 100644 index 00000000..e8b6d081 --- /dev/null +++ b/test/strip-ink-redraw-bloat.test.ts @@ -0,0 +1,225 @@ +/** + * Regression tests for stripInkRedrawBloat() in session-routes.ts. + * + * Background: this helper trims the dense VPA (CSI n d) escape clusters that + * Ink emits while animating the spinner / status bar so terminal-tail responses + * stay manageable. The previous implementation collapsed *everything* after + * the first VPA, which silently discarded 100KB+ of legitimate streamed + * response text. The clustering rewrite shipped silently inside the v0.6.7 + * "chore: version packages" commit (dcc814f) — these tests lock the algorithm + * down so neither the silent data-loss bug nor the threshold constants + * (FRAME_GAP=8KB, MIN_BLOAT_SIZE=32KB) regress. + * + * Pure (string)=>string helper, no I/O — synchronous tests, no port needed. + */ + +import { describe, it, expect } from 'vitest'; +import { stripInkRedrawBloat } from '../src/web/routes/session-routes.js'; + +const VPA = '\x1b[10d'; // VPA escape: move cursor to row 10. 5 bytes. +const VPA_LEN = VPA.length; +// Single counting regex shared across tests so the no-control-regex +// disable lives in one place. +// eslint-disable-next-line no-control-regex +const VPA_RE = /\x1b\[\d+d/g; +function countVpa(s: string): number { + return (s.match(VPA_RE) || []).length; +} + +/** Build a buffer of `count` VPAs with `gap` bytes of filler between them. */ +function vpaCluster(count: number, gap: number, fillerChar = ' '): string { + const filler = fillerChar.repeat(gap); + const parts: string[] = []; + for (let i = 0; i < count; i++) { + if (i > 0) parts.push(filler); + parts.push(VPA); + } + return parts.join(''); +} + +/** Span (bytes from first VPA's start to last VPA's start) of a cluster + * built by vpaCluster(count, gap). */ +function clusterSpan(count: number, gap: number): number { + return (count - 1) * (gap + VPA_LEN); +} + +describe('stripInkRedrawBloat', () => { + // ─── Early-out paths ───────────────────────────────────────────────────── + + it('returns empty buffer unchanged', () => { + expect(stripInkRedrawBloat('')).toBe(''); + }); + + it('returns buffer with no VPA escapes unchanged', () => { + const buf = 'Hello, this is a normal Claude response.\n'.repeat(100); + expect(stripInkRedrawBloat(buf)).toBe(buf); + }); + + it('returns buffer with fewer than 10 VPAs unchanged (even if total is large)', () => { + // 9 VPAs is below the early-out threshold; helper returns input verbatim + // regardless of how much filler sits between them. + const buf = vpaCluster(9, 50_000); // ~450KB of filler + expect(stripInkRedrawBloat(buf)).toBe(buf); + }); + + // ─── Small cluster preserved ───────────────────────────────────────────── + + it('preserves a cluster smaller than MIN_BLOAT_SIZE (32KB span)', () => { + // 50 VPAs, 100B apart -> span ~5.2KB, well under 32KB + const cluster = vpaCluster(50, 100); + expect(clusterSpan(50, 100)).toBeLessThan(32 * 1024); + const buf = 'PREFIX\n' + cluster + '\nSUFFIX'; + expect(stripInkRedrawBloat(buf)).toBe(buf); + }); + + // ─── Big cluster collapsed ─────────────────────────────────────────────── + + it('collapses a single big cluster but preserves bytes before and after', () => { + // 50 VPAs, 700B apart -> span 49*(700+5) = 34_545B, comfortably over 32KB + const cluster = vpaCluster(50, 700); + expect(clusterSpan(50, 700)).toBeGreaterThanOrEqual(32 * 1024); + const prefix = 'BEFORE_THE_BLOAT\n'; + const suffix = '\nAFTER_THE_BLOAT_THIS_IS_THE_RESPONSE_TEXT_THAT_USED_TO_BE_LOST'; + const buf = prefix + cluster + suffix; + + const out = stripInkRedrawBloat(buf); + + // Both ends survive — that was the silent bug + expect(out.startsWith(prefix)).toBe(true); + expect(out.endsWith(suffix)).toBe(true); + // Output is much shorter than the input + expect(out.length).toBeLessThan(buf.length); + // Exactly one VPA remains (the last frame's), not all 50 + expect(countVpa(out)).toBe(1); + }); + + // ─── THE REGRESSION: response text BETWEEN big clusters survives ───────── + + it('preserves response text between two big clusters (the silent-data-loss bug)', () => { + // Make each cluster large enough that an old "keep just the tail" approach + // (the audit described it as "keep only the last 64KB after the first VPA") + // would push any in-between response text out of the kept window. + // 200 VPAs at 700B gap -> ~140KB span per cluster, total buffer >300KB. + const clusterA = vpaCluster(200, 700); + const clusterB = vpaCluster(200, 700); + expect(clusterSpan(200, 700)).toBeGreaterThan(64 * 1024); + + const responseText = + '\n\n## Here is my detailed answer\n\n' + + 'This is the kind of streamed response text that the old first-VPA\n' + + 'algorithm silently dropped. Multiple paragraphs of it. Indented blocks,\n' + + 'code samples, the entire conversation. Losing this was a silent bug\n' + + 'that the changelog mentioned but no test had ever locked down.\n\n' + + '```ts\n' + + 'function example() { return 42; }\n' + + '```\n\n' + + 'And a closing paragraph.'; + // Gap between clusters must exceed FRAME_GAP (8KB) so they're treated + // as separate clusters, not merged into one giant one. + const padded = responseText + '\n' + ' '.repeat(8 * 1024 + 100); + const buf = 'INTRO\n' + clusterA + padded + clusterB + '\nOUTRO'; + expect(buf.length).toBeGreaterThan(280 * 1024); // sanity: well past any 64KB window + + const out = stripInkRedrawBloat(buf); + + // Every paragraph of response text survives intact. This is the assertion + // that would have caught the silent-data-loss bug — under a "keep the last + // 64KB after the first VPA" approach, all of responseText falls outside + // the kept window and is lost. + expect(out).toContain('Here is my detailed answer'); + expect(out).toContain('the old first-VPA'); + expect(out).toContain('function example() { return 42; }'); + expect(out).toContain('And a closing paragraph.'); + // Bookends survive too. + expect(out.startsWith('INTRO\n')).toBe(true); + expect(out.endsWith('\nOUTRO')).toBe(true); + // Each big cluster collapses to a single VPA (its last frame). + expect(countVpa(out)).toBe(2); + }); + + // ─── Mixed: small clusters preserved alongside big ones ────────────────── + + it('preserves small clusters when a big cluster is also present', () => { + const small = vpaCluster(50, 100); // ~5.2KB span, kept as-is + const big = vpaCluster(50, 700); // ~34KB span, collapsed + // Separate them with > FRAME_GAP filler so they stay distinct clusters. + const gap = ' '.repeat(8 * 1024 + 100); + const buf = small + gap + 'MID_CONTENT' + gap + big + 'TAIL'; + + const out = stripInkRedrawBloat(buf); + + // Small cluster survives intact: all 50 VPAs still there. + // Big cluster collapsed to 1 VPA. Total = 50 + 1 = 51. + expect(countVpa(out)).toBe(51); + expect(out).toContain('MID_CONTENT'); + expect(out.endsWith('TAIL')).toBe(true); + }); + + // ─── FRAME_GAP boundary: two clusters separated by exactly > 8KB ───────── + + it('treats VPAs separated by > FRAME_GAP (8KB) as different clusters', () => { + // Two big clusters with a 9KB gap between them must NOT be merged. + const clusterA = vpaCluster(50, 700); + const clusterB = vpaCluster(50, 700); + const gap = ' '.repeat(9 * 1024); // > 8KB FRAME_GAP + + const buf = clusterA + gap + clusterB; + const out = stripInkRedrawBloat(buf); + + // Two separate big clusters -> 2 VPAs survive. + expect(countVpa(out)).toBe(2); + // Gap content survives. + expect(out).toContain(gap); + }); + + it('treats VPAs separated by <= FRAME_GAP (8KB) as the same cluster', () => { + // Two would-be-separate clusters with a 1KB gap merge into one big cluster. + const left = vpaCluster(30, 700); + const right = vpaCluster(30, 700); + const gap = ' '.repeat(1024); // well under 8KB FRAME_GAP + const buf = left + gap + right; + + const out = stripInkRedrawBloat(buf); + // Merged into one big cluster -> 1 VPA survives, gap content is gone too. + expect(countVpa(out)).toBe(1); + }); + + // ─── Big cluster at end of buffer ──────────────────────────────────────── + + it('preserves the last frame when a big cluster is at the end of the buffer', () => { + const cluster = vpaCluster(50, 700); + const buf = 'HEAD\n' + cluster; + const out = stripInkRedrawBloat(buf); + + expect(out.startsWith('HEAD\n')).toBe(true); + // Exactly one VPA (the last frame's) at the tail. + expect(countVpa(out)).toBe(1); + expect(out.endsWith(VPA)).toBe(true); + }); + + // ─── Idempotency ───────────────────────────────────────────────────────── + + it('is idempotent: stripping an already-stripped buffer is a no-op', () => { + // After one pass a big cluster shrinks to ~1 VPA, putting the buffer + // below the early-out threshold (positions.length < 10), so a second + // pass returns it untouched. + const buf = 'A' + vpaCluster(50, 700) + 'B'; + const once = stripInkRedrawBloat(buf); + const twice = stripInkRedrawBloat(once); + expect(twice).toBe(once); + }); + + // ─── Realistic size guardrail ──────────────────────────────────────────── + + it('shrinks a 200KB Ink-bloat buffer down by an order of magnitude', () => { + // Roughly the shape of a real "Claude is thinking" terminal buffer. + const cluster = vpaCluster(300, 700); // ~210KB span + const buf = 'Question: Tell me about TypeScript.\n' + cluster + '\nAnswer: TypeScript is...'; + + const out = stripInkRedrawBloat(buf); + expect(buf.length).toBeGreaterThan(200 * 1024); + expect(out.length).toBeLessThan(buf.length / 10); + expect(out).toContain('Question: Tell me about TypeScript.'); + expect(out).toContain('Answer: TypeScript is...'); + }); +});