/**
* Tests for flatSlice — the SlicedString-retention fix.
*
* Background: `String.prototype.slice` returns a V8 SlicedString that
* retains its entire parent string. Storing short slices of large session
* strings (100KB+ agent prompts) in the long-lived session cache pinned
* gigabytes of parent buffers during cold parses, OOMing the default heap
* (issue observed at ~5.5GB peak for 3.2GB of kiro session files; ~300MB
* after flattening).
*/
import { describe, it, expect } from 'vitest'
import { flatSlice, flatString } from '../src/content-utils.js'
describe('flatSlice', () => {
it('returns the prefix for strings over the bound', () => {
const big = 'x'.repeat(10_000)
const out = flatSlice(big, 500)
expect(out.length).toBe(500)
expect(out).toBe(big.slice(0, 500))
})
it('returns the string itself when within the bound', () => {
const small = 'hello world'
expect(flatSlice(small, 500)).toBe(small)
})
it('handles multi-byte characters without corruption', () => {
// Emoji + CJK near the boundary — Buffer round-trip must not produce
// invalid UTF-8 replacement chars for chars fully inside the slice.
const s = '🐾'.repeat(300) // each emoji is 2 UTF-16 code units
const out = flatSlice(s, 500)
expect(out).toBe(s.slice(0, 500))
})
it('preserves a lone surrogate at a mid-pair cut', () => {
// A cut landing between the high and low surrogate of a pair leaves a
// lone surrogate. utf16le round-trips code units byte-for-byte, so the
// lone surrogate survives intact (unlike utf-8, which would replace it
// with U+FFFD).
const s = 'ab' + '🐾'.repeat(300) // odd offset puts every emoji across even boundaries
const out = flatSlice(s, 501) // cuts mid-pair
expect(out.length).toBe(501)
expect(out.slice(0, 500)).toBe(s.slice(0, 500)) // content before the cut intact
expect(out.charCodeAt(500)).toBe(s.charCodeAt(500)) // lone surrogate preserved
})
it('does not retain the parent of an already-sliced view', () => {
// The bug this early-return removal fixes: provider adapters pre-truncate
// with .slice(0, 500) before the cache-site flatSlice call, so a naive
// "already within bound" early return would skip flattening and leave
// the SlicedString pinning its 100KB parent.
const before = process.memoryUsage().heapUsed
const kept: string[] = []
for (let i = 0; i < 1000; i++) {
const parent = (i % 10).toString().repeat(100_000) + i
const preSliced = parent.slice(0, 500)
kept.push(flatSlice(preSliced, 2000))
}
if (typeof global.gc === 'function') global.gc()
const after = process.memoryUsage().heapUsed
const growthMB = (after - before) / 1048576
expect(kept.length).toBe(1000)
expect(growthMB).toBeLessThan(50)
})
it('does not retain the parent string (heap growth stays bounded)', () => {
// Property test for the retention fix: keep 1000 short prefixes of
// 1000 distinct 100KB strings. With plain .slice() each prefix pins its
// 100KB parent (~200MB in UTF-16 total). With flatSlice, retained data
// is ~1000 × 500 chars ≈ 1MB. Assert heap growth is far below the
// retention scenario. Threshold is generous (50MB) to be CI-safe while
// still failing decisively if retention returns (>190MB). When the test
// runner exposes gc (vitest under --expose-gc), force a collection so
// transient parent garbage doesn't inflate the measurement.
const before = process.memoryUsage().heapUsed
const kept: string[] = []
for (let i = 0; i < 1000; i++) {
// Distinct content so V8 cannot intern/share the parents.
const parent = (i % 10).toString().repeat(100_000)
kept.push(flatSlice(parent + i, 500))
}
if (typeof global.gc === 'function') global.gc()
const after = process.memoryUsage().heapUsed
const growthMB = (after - before) / 1048576
expect(kept.length).toBe(1000)
expect(growthMB).toBeLessThan(50)
})
})
describe('flatString', () => {
it('returns an equal string for any input', () => {
expect(flatString('')).toBe('')
expect(flatString('hello')).toBe('hello')
expect(flatString('🐾 multi-byte ✓')).toBe('🐾 multi-byte ✓')
})
it('does not retain the parent of a regex match group', () => {
// match[1] is a SlicedString retaining the entire subject. flatString
// must break that link: keep 1000 short match groups of distinct 100KB
// subjects and assert bounded heap growth (same thresholds as the
// flatSlice retention test).
const before = process.memoryUsage().heapUsed
const kept: string[] = []
for (let i = 0; i < 1000; i++) {
const subject = `tool_${i}` + (i % 10).toString().repeat(100_000)
const m = /([^<]+)<\/name>/.exec(subject)
kept.push(flatString(m![1]!))
}
if (typeof global.gc === 'function') global.gc()
const after = process.memoryUsage().heapUsed
const growthMB = (after - before) / 1048576
expect(kept.length).toBe(1000)
expect(growthMB).toBeLessThan(50)
})
})