mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
test(routes): regression coverage for stripInkRedrawBloat
The clustering rewrite of stripInkRedrawBloat() shipped silently inside
the v0.6.7 "chore: version packages" commit (dcc814f). The previous
implementation discarded everything after the first VPA escape — silently
dropping 100KB+ of legitimate streamed response text on every long
Claude turn. The fix landed without any test coverage, so a regression
back to the old shape would be invisible until users noticed missing
conversation history.
Export the function (it's a pure (string)=>string helper) and add 12
tests covering:
- The early-out paths (empty buffer, no VPAs, fewer than 10 VPAs)
- Small clusters preserved (< MIN_BLOAT_SIZE = 32KB span)
- Big clusters collapsed to a single trailing VPA
- The silent-data-loss bug: response text BETWEEN two big clusters
is preserved (input >280KB so any "keep just the tail" approach
would push the response text out of its window — verified locally
that a simulated old impl fails the assertion)
- FRAME_GAP boundary on both sides (>8KB splits clusters; <=8KB merges)
- Mixed small + big in the same buffer
- Big cluster at end-of-buffer keeps the last frame
- Idempotency: a second pass is a no-op
- Realistic 200KB+ input shrinks by an order of magnitude
Total runtime ~12ms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
@@ -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...');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user