retention.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. import { describe, expect, it } from 'vitest'
  2. import {
  3. describeOmitted,
  4. formatRetentionNotice,
  5. ItemRetainer,
  6. type Omitted,
  7. type RetentionNotice,
  8. TextRetainer,
  9. } from '@deepseek-ai/dsh-retention'
  10. /** Decode a RetainedText via a round-trip helper for readable UTF-8 assertions. */
  11. const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
  12. describe('ItemRetainer — head retention', () => {
  13. it('keeps the first maxItems while callers keep draining for an exact omitted count', () => {
  14. const r = new ItemRetainer<string>({ kind: 'head', maxItems: 2 })
  15. expect(r.push('a')).toEqual({ kept: true, truncated: false })
  16. expect(r.push('b')).toEqual({ kept: true, truncated: false })
  17. expect(r.push('c')).toEqual({ kept: false, truncated: true })
  18. const result = r.finish()
  19. expect(result.items).toEqual(['a', 'b'])
  20. expect(result.kept).toBe(2)
  21. expect(result.seen).toBe(3)
  22. expect(result.truncated).toBe(true)
  23. expect(result.omitted).toEqual<Omitted>({ kind: 'exact', count: 1 })
  24. })
  25. it('reports none when everything fits', () => {
  26. const r = new ItemRetainer<number>({ kind: 'head', maxItems: 3 })
  27. r.push(1)
  28. r.push(2)
  29. const result = r.finish()
  30. expect(result.items).toEqual([1, 2])
  31. expect(result.truncated).toBe(false)
  32. expect(result.omitted).toEqual<Omitted>({ kind: 'none' })
  33. })
  34. it('keeps draining past the cap and reports an exact omitted count', () => {
  35. const r = new ItemRetainer<string>({ kind: 'head', maxItems: 1 })
  36. expect(r.push('a')).toEqual({ kept: true, truncated: false })
  37. expect(r.push('b')).toEqual({ kept: false, truncated: true })
  38. expect(r.push('c')).toEqual({ kept: false, truncated: true })
  39. const result = r.finish()
  40. expect(result.items).toEqual(['a'])
  41. expect(result.seen).toBe(3)
  42. expect(result.omitted).toEqual<Omitted>({ kind: 'exact', count: 2 })
  43. })
  44. })
  45. describe('ItemRetainer — zero budget', () => {
  46. it('keeps nothing and counts every pushed item as omitted', () => {
  47. const r = new ItemRetainer<string>({ kind: 'head', maxItems: 0 })
  48. expect(r.push('a')).toEqual({ kept: false, truncated: true })
  49. const result = r.finish()
  50. expect(result.items).toEqual([])
  51. expect(result.kept).toBe(0)
  52. expect(result.omitted).toEqual<Omitted>({ kind: 'exact', count: 1 })
  53. })
  54. it('rejects a non-integer / negative maxItems', () => {
  55. expect(() => new ItemRetainer({ kind: 'head', maxItems: -1 }))
  56. .toThrow(/maxItems must be a non-negative integer/)
  57. expect(() => new ItemRetainer({ kind: 'head', maxItems: 1.5 }))
  58. .toThrow(/maxItems must be a non-negative integer/)
  59. })
  60. })
  61. describe('TextRetainer — head (exact omission, reads to end)', () => {
  62. it('keeps the prefix and counts omitted bytes exactly', () => {
  63. const r = new TextRetainer({ kind: 'head', maxBytes: 5 })
  64. expect(r.push('abc')).toEqual({ kept: true, truncated: false })
  65. // 'de' fills the cap exactly (5 bytes) — still fully kept.
  66. expect(r.push('de')).toEqual({ kept: true, truncated: false })
  67. expect(r.push('fgh')).toEqual({ kept: false, truncated: true })
  68. const result = r.finish()
  69. expect(result.text).toBe('abcde')
  70. expect(result.truncated).toBe(true)
  71. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 3 })
  72. })
  73. it('flags a partially-dropped chunk as not fully kept', () => {
  74. const r = new TextRetainer({ kind: 'head', maxBytes: 4 })
  75. r.push('ab')
  76. // 'cde' straddles the cap: 'c','d' fit, 'e' drops → kept:false.
  77. expect(r.push('cde')).toEqual({ kept: false, truncated: true })
  78. expect(r.finish().text).toBe('abcd')
  79. })
  80. it('keeps draining past the cap', () => {
  81. const r = new TextRetainer({ kind: 'head', maxBytes: 3 })
  82. r.push('abc')
  83. expect(r.push('defg')).toEqual({ kept: false, truncated: true })
  84. const result = r.finish()
  85. expect(result.text).toBe('abc')
  86. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 4 })
  87. })
  88. })
  89. describe('TextRetainer — tail (exact omission, reads to end)', () => {
  90. it('keeps the final maxBytes and reports exact omission', () => {
  91. const r = new TextRetainer({ kind: 'tail', maxBytes: 4 })
  92. expect(r.push('hello')).toEqual({ kept: false, truncated: true })
  93. r.push('world')
  94. const result = r.finish()
  95. expect(result.text).toBe('orld') // last 4 bytes of 'helloworld'
  96. expect(result.truncated).toBe(true)
  97. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 6 })
  98. })
  99. it('keeps everything when the stream is under the cap', () => {
  100. const r = new TextRetainer({ kind: 'tail', maxBytes: 100 })
  101. r.push('short')
  102. const result = r.finish()
  103. expect(result.text).toBe('short')
  104. expect(result.truncated).toBe(false)
  105. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'none' })
  106. })
  107. it('drops old chunks as they slide out of the tail window', () => {
  108. const r = new TextRetainer({ kind: 'tail', maxBytes: 3 })
  109. for (const c of ['11', '22', '33', '44']) r.push(c)
  110. // Only the final 3 bytes survive; earlier whole chunks are dropped.
  111. expect(r.finish().text).toBe('344')
  112. })
  113. })
  114. describe('TextRetainer — headTail (prefix + suffix, omit the middle)', () => {
  115. it('keeps a stable head and tail, omitting the middle exactly', () => {
  116. const r = new TextRetainer({ kind: 'headTail', headBytes: 3, tailBytes: 3 })
  117. r.push('abcdefghij') // 10 bytes: head 'abc', tail 'hij', middle 'defg' omitted
  118. const result = r.finish()
  119. expect(result.text).toBe('abchij')
  120. expect(result.truncated).toBe(true)
  121. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 4 })
  122. })
  123. it('does not double-count when head+tail cover the whole stream', () => {
  124. const r = new TextRetainer({ kind: 'headTail', headBytes: 3, tailBytes: 3 })
  125. r.push('abcdef') // exactly head(3) + tail(3), nothing omitted
  126. const result = r.finish()
  127. expect(result.text).toBe('abcdef')
  128. expect(result.truncated).toBe(false)
  129. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'none' })
  130. })
  131. it('does not drop a codepoint that spans the head|tail split when nothing is omitted', () => {
  132. // Regression: with head+tail covering the whole stream, the split is
  133. // artificial — a multibyte codepoint may straddle it. 'éab' is C3 A9 61 62
  134. // (4 bytes); headBytes 1 + tailBytes 3 covers all 4 with omitted === 0, but
  135. // the split falls INSIDE 'é'. The bytes are contiguous, so the full 'éab'
  136. // must survive — not be trimmed to 'ab'.
  137. const r = new TextRetainer({ kind: 'headTail', headBytes: 1, tailBytes: 3 })
  138. r.push('éab')
  139. const result = r.finish()
  140. expect(result.text).toBe('éab')
  141. expect(result.truncated).toBe(false)
  142. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'none' })
  143. })
  144. it('still trims boundary partials once a real middle is omitted', () => {
  145. // With a genuine gap the two sides ARE true cuts: '€' (3 bytes) split across
  146. // the omitted middle must not resurface as a replacement char on either side.
  147. const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 })
  148. r.push('a€€b') // 8 bytes; head 'a'+partial, tail partial+'b', middle omitted
  149. const result = r.finish()
  150. expect(result.truncated).toBe(true)
  151. expect(result.text).not.toContain('�')
  152. expect(result.text.startsWith('a')).toBe(true)
  153. expect(result.text.endsWith('b')).toBe(true)
  154. })
  155. })
  156. describe('TextRetainer — zero budgets', () => {
  157. it('head maxBytes 0 keeps nothing and counts every byte exactly', () => {
  158. const r = new TextRetainer({ kind: 'head', maxBytes: 0 })
  159. expect(r.push('x')).toEqual({ kept: false, truncated: true })
  160. const result = r.finish()
  161. expect(result.text).toBe('')
  162. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 1 })
  163. })
  164. it('an empty stream omits nothing', () => {
  165. const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 })
  166. const result = r.finish()
  167. expect(result.text).toBe('')
  168. expect(result.truncated).toBe(false)
  169. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'none' })
  170. })
  171. it('rejects non-integer / negative byte budgets', () => {
  172. expect(() => new TextRetainer({ kind: 'head', maxBytes: -1 }))
  173. .toThrow(/maxBytes must be a non-negative integer/)
  174. expect(() => new TextRetainer({ kind: 'tail', maxBytes: 2.5 }))
  175. .toThrow(/maxBytes must be a non-negative integer/)
  176. expect(() => new TextRetainer({ kind: 'headTail', headBytes: -1, tailBytes: 2 }))
  177. .toThrow(/headBytes must be a non-negative integer/)
  178. expect(() => new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 1.1 }))
  179. .toThrow(/tailBytes must be a non-negative integer/)
  180. })
  181. })
  182. describe('TextRetainer — UTF-8 boundary handling', () => {
  183. it('trims a partial codepoint at the head cut instead of emitting U+FFFD', () => {
  184. // '€' is 3 bytes (E2 82 AC). A 2-byte head cap keeps 'a' (61) + the first
  185. // byte of '€' (E2); that partial lead byte must be trimmed, not decoded to
  186. // a replacement char.
  187. const r = new TextRetainer({ kind: 'head', maxBytes: 2 })
  188. r.push('a€b') // bytes: 61 E2 82 AC 62
  189. const result = r.finish()
  190. expect(result.text).toBe('a') // partial '€' dropped, no U+FFFD
  191. expect(result.text).not.toContain('�')
  192. // Omission counts bytes ACTUALLY absent from the returned text, including
  193. // the partial 'E2' the boundary trim dropped: 5 total − 1 retained = 4
  194. // (not the pre-trim budget of 3, which would overstate what was kept).
  195. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 4 })
  196. })
  197. it('trims a leading partial codepoint at the tail cut', () => {
  198. // Tail cap 2 over 'a€b' (5 bytes) keeps AC 62 — AC is a continuation byte
  199. // (the middle of '€'); the leading continuation byte is dropped so the tail
  200. // begins on a boundary.
  201. const r = new TextRetainer({ kind: 'tail', maxBytes: 2 })
  202. r.push('a€b')
  203. const result = r.finish()
  204. expect(result.text).toBe('b') // partial '€' at the front dropped
  205. expect(result.text).not.toContain('�')
  206. // Honest count: 5 total − 1 retained ('b') = 4, including the trimmed AC.
  207. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 4 })
  208. })
  209. it('omitted count matches the bytes actually absent, across a headTail boundary trim', () => {
  210. // Regression: the exact count must equal total − retained (post-trim), never
  211. // the pre-trim budget. 'a€€b' is 8 bytes (61 E2828C… ×2 61? no: 61 E2 82 AC
  212. // E2 82 AC 62). headBytes 2 keeps 'a'+partial-E2 → trims to 'a' (1 byte);
  213. // tailBytes 2 keeps partial-AC+'b' → trims to 'b' (1 byte). Retained text is
  214. // 2 bytes, so omitted must be 8 − 2 = 6 — not the budget's 8 − 2 − 2 = 4.
  215. const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 })
  216. r.push('a€€b')
  217. const result = r.finish()
  218. const retainedBytes = new TextEncoder().encode(result.text).length
  219. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 8 - retainedBytes })
  220. })
  221. it('preserves a whole multibyte codepoint that fits exactly', () => {
  222. const r = new TextRetainer({ kind: 'head', maxBytes: 3 })
  223. r.push('€x') // '€' is exactly 3 bytes
  224. expect(r.finish().text).toBe('€')
  225. })
  226. it('does not reconstruct a codepoint across the omitted middle', () => {
  227. // headBytes ends mid-'€' and tailBytes starts mid-another '€'; neither cut
  228. // may glue a valid codepoint across the gap.
  229. const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 })
  230. r.push('€€€') // 9 bytes
  231. const result = r.finish()
  232. expect(result.text).not.toContain('�')
  233. expect(result.truncated).toBe(true)
  234. })
  235. it('accepts a raw Uint8Array chunk', () => {
  236. const r = new TextRetainer({ kind: 'head', maxBytes: 2 })
  237. r.push(utf8('xy'))
  238. r.push(utf8('z'))
  239. expect(r.finish().text).toBe('xy')
  240. })
  241. it('trims a partial 2-byte codepoint at the head cut', () => {
  242. // 'é' is 2 bytes (C3 A9). A 2-byte head cap over 'aé' keeps 'a' (61) + the
  243. // lead byte of 'é' (C3) — an incomplete 2-byte sequence to trim.
  244. const r = new TextRetainer({ kind: 'head', maxBytes: 2 })
  245. r.push('aé') // bytes: 61 C3 A9
  246. const result = r.finish()
  247. expect(result.text).toBe('a')
  248. expect(result.text).not.toContain('�')
  249. })
  250. it('trims a partial 4-byte codepoint (emoji) at the head cut', () => {
  251. // '😀' is 4 bytes (F0 9F 98 80). A 3-byte head cap keeps 'a' + the first two
  252. // bytes of the emoji — an incomplete 4-byte sequence that must be trimmed.
  253. const r = new TextRetainer({ kind: 'head', maxBytes: 3 })
  254. r.push('a😀') // bytes: 61 F0 9F 98 80
  255. const result = r.finish()
  256. expect(result.text).toBe('a')
  257. expect(result.text).not.toContain('�')
  258. })
  259. it('keeps a whole 4-byte codepoint that fits exactly', () => {
  260. const r = new TextRetainer({ kind: 'head', maxBytes: 4 })
  261. r.push('😀x')
  262. expect(r.finish().text).toBe('😀')
  263. })
  264. it('leaves a head cut ending on a stray continuation run untouched', () => {
  265. // A cut whose trailing bytes are ALL continuation bytes with no lead in
  266. // reach is not a trimmable incomplete sequence — the trimmer bails (no lead
  267. // byte found) and leaves them for the non-fatal decoder to replace.
  268. const r = new TextRetainer({ kind: 'head', maxBytes: 2 })
  269. // 0x80 0x80 are bare continuation bytes; 'z' follows so the head keeps just
  270. // the two continuation bytes and the cut lands right after them.
  271. r.push(new Uint8Array([0x80, 0x80, 0x7a]))
  272. const result = r.finish()
  273. // The trimmer did not throw and did not eat the bytes as a partial sequence;
  274. // only the trailing 'z' is omitted by the 2-byte cap.
  275. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 1 })
  276. })
  277. it('leaves a head cut ending on an invalid lead byte untouched', () => {
  278. // 0xF8 is not a valid UTF-8 lead byte (only 0x00–0xF7 lead). The trimmer
  279. // recognizes it as "not a lead" (expected length 0) and leaves the byte in
  280. // place rather than trimming a phantom partial sequence.
  281. const r = new TextRetainer({ kind: 'head', maxBytes: 1 })
  282. r.push(new Uint8Array([0xf8, 0x61])) // 0xF8 kept, 'a' dropped by the 1-byte cap
  283. const result = r.finish()
  284. expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 1 })
  285. })
  286. })
  287. describe('describeOmitted — false precision safety', () => {
  288. it('prints an exact count for exact omission', () => {
  289. expect(describeOmitted({ kind: 'exact', count: 3 }, 'items')).toBe('Omitted 3 items.')
  290. expect(describeOmitted({ kind: 'exact', count: 12 }, 'bytes')).toBe('Omitted 12 bytes.')
  291. })
  292. it('prints NO count for unknown omission', () => {
  293. expect(describeOmitted({ kind: 'unknown' }, 'lines')).toBe('More lines were omitted.')
  294. })
  295. it('returns empty string when nothing was omitted', () => {
  296. expect(describeOmitted({ kind: 'none' }, 'chars')).toBe('')
  297. })
  298. })
  299. describe('formatRetentionNotice', () => {
  300. const notice = (omitted: Omitted): RetentionNotice => ({
  301. scope: 'grep',
  302. strategy: 'head',
  303. unit: 'items',
  304. limit: 100,
  305. kept: 100,
  306. omitted,
  307. })
  308. it('joins the standardized omission clause with the tool recovery guidance', () => {
  309. const out = formatRetentionNotice(
  310. notice({ kind: 'exact', count: 25 }),
  311. ({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
  312. )
  313. expect(out).toBe('Omitted 25 items. Results capped at 100. Narrow the pattern, path, or include to see more.')
  314. })
  315. it('omits the empty half when nothing was omitted', () => {
  316. const out = formatRetentionNotice(notice({ kind: 'none' }), () => 'Recovery text.')
  317. expect(out).toBe('Recovery text.')
  318. })
  319. it('omits the empty half when the tool supplies no recovery text', () => {
  320. const out = formatRetentionNotice(notice({ kind: 'exact', count: 2 }), () => '')
  321. expect(out).toBe('Omitted 2 items.')
  322. })
  323. it('passes the full notice to the recovery builder (limit as a head/tail pair)', () => {
  324. const headTail: RetentionNotice = {
  325. scope: 'bash stdout',
  326. strategy: 'headTail',
  327. unit: 'bytes',
  328. limit: { head: 2_000, tail: 2_000 },
  329. kept: 4_000,
  330. omitted: { kind: 'exact', count: 500 },
  331. }
  332. const out = formatRetentionNotice(headTail, n =>
  333. typeof n.limit === 'object' ? `Kept ${n.limit.head}B head + ${n.limit.tail}B tail.` : '')
  334. expect(out).toBe('Omitted 500 bytes. Kept 2000B head + 2000B tail.')
  335. })
  336. })