1- import { afterAll , beforeAll , expect , test } from 'bun:test'
2- import { mkdir , mkdtemp , rm , writeFile } from 'node:fs/promises'
1+ import { afterAll , beforeAll , expect , spyOn , test } from 'bun:test'
2+ import { mkdir , mkdtemp , readFile , rename , rm , truncate , writeFile } from 'node:fs/promises'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
55import { createSandboxedTestEnvironment } from '../../../scripts/pr/test-environment.js'
66
7- const checkpointBudget = 16 * 1024 * 1024
7+ // Checkpoints drop tool output while parsing; everything else stays resident.
8+ const retainedBudget = 24 * 1024 * 1024
9+ const sourceBudget = 256 * 1024 * 1024
810const originalEnvironment = { ...process . env }
911let sandbox : string
1012let workDir : string
1113let configDir : string
1214let handleSessionsApi : typeof import ( './sessions.js' ) . handleSessionsApi
15+ let sessionService : typeof import ( '../services/sessionService.js' ) . sessionService
16+ let listSessionTurnCheckpoints : typeof import ( '../services/sessionRewindService.js' ) . listSessionTurnCheckpoints
1317
1418beforeAll ( async ( ) => {
1519 sandbox = await mkdtemp ( join ( tmpdir ( ) , 'qa-005-checkpoint-budget-' ) )
@@ -23,6 +27,8 @@ beforeAll(async () => {
2327 await mkdir ( workDir )
2428 await mkdir ( join ( configDir , 'projects' , 'fixture' ) , { recursive : true } )
2529 handleSessionsApi = ( await import ( './sessions.js' ) ) . handleSessionsApi
30+ sessionService = ( await import ( '../services/sessionService.js' ) ) . sessionService
31+ listSessionTurnCheckpoints = ( await import ( '../services/sessionRewindService.js' ) ) . listSessionTurnCheckpoints
2632} )
2733
2834afterAll ( async ( ) => {
@@ -39,7 +45,12 @@ function row(type: string, content: unknown, extra: Record<string, unknown> = {}
3945 } ) + '\n'
4046}
4147
42- async function createTranscript ( content : string , activityCount = 0 , totalBytes ?: number ) {
48+ async function createTranscript (
49+ content : string ,
50+ activityCount = 0 ,
51+ totalBytes ?: number ,
52+ padding : 'records' | 'toolOutput' = 'records' ,
53+ ) {
4354 const sessionId = crypto . randomUUID ( )
4455 const filePath = join ( configDir , 'projects' , 'fixture' , `${ sessionId } .jsonl` )
4556 const changedPath = join ( workDir , `${ sessionId } .txt` )
@@ -56,7 +67,16 @@ async function createTranscript(content: string, activityCount = 0, totalBytes?:
5667 entries . push ( row ( 'assistant' , [ { type : 'tool_use' , id : 'write' , name : 'Write' , input : { file_path : changedPath , content } } ] ) )
5768 entries . push ( row ( 'user' , [ { type : 'tool_result' , tool_use_id : 'write' , content : 'Created successfully' } ] ) )
5869 entries . push ( row ( 'assistant' , [ { type : 'text' , text : 'Synthetic replay complete' } ] ) )
59- if ( totalBytes !== undefined ) {
70+ if ( totalBytes !== undefined && padding === 'toolOutput' ) {
71+ // Large Read results, the usual bulk of a long session.
72+ entries . push ( row ( 'assistant' , [ { type : 'tool_use' , id : 'read' , name : 'Read' , input : { file_path : changedPath } } ] ) )
73+ let remaining = totalBytes - Buffer . byteLength ( entries . join ( '' ) )
74+ while ( remaining > 0 ) {
75+ const line = row ( 'user' , [ { type : 'tool_result' , tool_use_id : 'read' , content : 'x' . repeat ( 60 * 1024 ) } ] )
76+ entries . push ( line )
77+ remaining -= Buffer . byteLength ( line )
78+ }
79+ } else if ( totalBytes !== undefined ) {
6080 let remaining = totalBytes - Buffer . byteLength ( entries . join ( '' ) )
6181 const overhead = Buffer . byteLength ( JSON . stringify ( { type : 'progress' , padding : '' } ) + '\n' )
6282 while ( remaining > 0 ) {
@@ -79,8 +99,9 @@ async function get(sessionId: string, resource: string) {
7999}
80100
81101test ( 'QA-005 keeps the full checkpoint limit while retaining bounded workspace files after activity overflow' , async ( ) => {
82- const fixture = await createTranscript ( 'QA retained workspace evidence\n' , 2050 )
83- expect ( fixture . bytes ) . toBeGreaterThan ( checkpointBudget )
102+ // Agent calls are retained: their results link subagent transcripts.
103+ const fixture = await createTranscript ( 'QA retained workspace evidence\n' , 2600 )
104+ expect ( fixture . bytes ) . toBeGreaterThan ( retainedBudget )
84105 const checkpoints = await get ( fixture . sessionId , 'turn-checkpoints' )
85106 expect ( checkpoints . status ) . toBe ( 413 )
86107 expect ( await checkpoints . json ( ) ) . toMatchObject ( { error : 'HISTORY_CHECKPOINT_PREVIEW_LIMIT' } )
@@ -99,7 +120,7 @@ test('QA-005 keeps the full checkpoint limit while retaining bounded workspace f
99120} , 20_000 )
100121
101122test ( 'QA-005 oversized Write remains conservative even though the current file is readable' , async ( ) => {
102- const fixture = await createTranscript ( 'x' . repeat ( 128 * 1024 ) , 2050 )
123+ const fixture = await createTranscript ( 'x' . repeat ( 128 * 1024 ) , 2600 )
103124 const workspace = await get ( fixture . sessionId , 'workspace/status' )
104125 expect ( workspace . status ) . toBe ( 413 )
105126 expect ( await workspace . json ( ) ) . toMatchObject ( { error : 'HISTORY_WORKSPACE_LIMIT' } )
@@ -111,16 +132,156 @@ test('QA-005 oversized Write remains conservative even though the current file i
111132 expect ( await checkpoints . json ( ) ) . toMatchObject ( { error : 'HISTORY_CHECKPOINT_PREVIEW_LIMIT' } )
112133} , 20_000 )
113134
114- test ( 'QA-005 full checkpoint preview admits exactly 16MiB and rejects the next byte' , async ( ) => {
115- const admitted = await createTranscript ( 'boundary\n' , 0 , checkpointBudget )
116- expect ( admitted . bytes ) . toBe ( checkpointBudget )
117- const checkpoints = await get ( admitted . sessionId , 'turn-checkpoints' )
135+ async function expectPreviewOf ( sessionId : string , fileName : string ) {
136+ const checkpoints = await get ( sessionId , 'turn-checkpoints' )
118137 expect ( checkpoints . status ) . toBe ( 200 )
119138 const body = await checkpoints . json ( ) as { checkpoints : Array < { code : { filesChanged : string [ ] } } > }
120- expect ( body . checkpoints [ 0 ] ?. code . filesChanged ) . toContain ( join ( workDir , admitted . fileName ) )
121- const rejected = await createTranscript ( 'boundary\n' , 0 , checkpointBudget + 1 )
122- expect ( rejected . bytes ) . toBe ( checkpointBudget + 1 )
123- const response = await get ( rejected . sessionId , 'turn-checkpoints' )
139+ expect ( body . checkpoints [ 0 ] ?. code . filesChanged ) . toContain ( join ( workDir , fileName ) )
140+ const diff = await get ( sessionId , `turn-checkpoints/diff?userMessageIndex=0&path=${ fileName } ` )
141+ expect ( diff . status ) . toBe ( 200 )
142+ }
143+
144+ async function expectPreviewRejected ( sessionId : string ) {
145+ const response = await get ( sessionId , 'turn-checkpoints' )
124146 expect ( response . status ) . toBe ( 413 )
125147 expect ( await response . json ( ) ) . toMatchObject ( { error : 'HISTORY_CHECKPOINT_PREVIEW_LIMIT' } )
148+ }
149+
150+ test ( 'previews a session whose bulk is tool output far past the retained budget' , async ( ) => {
151+ const fixture = await createTranscript ( 'output heavy\n' , 0 , 2 * retainedBudget , 'toolOutput' )
152+ expect ( fixture . bytes ) . toBeGreaterThan ( 2 * retainedBudget )
153+ await expectPreviewOf ( fixture . sessionId , fixture . fileName )
154+ } , 20_000 )
155+
156+ test ( 'previews retained records under the budget and rejects them over it' , async ( ) => {
157+ const admitted = await createTranscript ( 'boundary\n' , 0 , retainedBudget - 1024 * 1024 )
158+ await expectPreviewOf ( admitted . sessionId , admitted . fileName )
159+ const rejected = await createTranscript ( 'boundary\n' , 0 , retainedBudget + 1024 * 1024 )
160+ await expectPreviewRejected ( rejected . sessionId )
161+ } , 20_000 )
162+
163+ test ( 'rejects a source above its budget without reading it' , async ( ) => {
164+ const fixture = await createTranscript ( 'oversized source\n' , 0 , undefined )
165+ // Sparse and newline-free: parsing it would build one 256 MiB line.
166+ await truncate ( join ( configDir , 'projects' , 'fixture' , `${ fixture . sessionId } .jsonl` ) , sourceBudget + 1 )
167+ await expectPreviewRejected ( fixture . sessionId )
168+ } , 20_000 )
169+
170+ type SnapshotRecord = { messageId : string ; backups : Record < string , string > ; update ?: boolean }
171+
172+ /** Snapshot-only session: each record maps tracked paths to backup contents. */
173+ async function createSnapshotSession ( records : SnapshotRecord [ ] , current : Record < string , string > ) {
174+ const sessionId = crypto . randomUUID ( )
175+ const historyDir = join ( configDir , 'file-history' , sessionId )
176+ await mkdir ( historyDir , { recursive : true } )
177+ const backupNames = new Map < string , string > ( )
178+ const lines : string [ ] = [ ]
179+ for ( const record of records ) {
180+ const trackedFileBackups : Record < string , unknown > = { }
181+ for ( const [ trackingPath , content ] of Object . entries ( record . backups ) ) {
182+ let name = backupNames . get ( content )
183+ if ( ! name ) {
184+ name = `backup-${ backupNames . size } @v1`
185+ backupNames . set ( content , name )
186+ await writeFile ( join ( historyDir , name ) , content )
187+ }
188+ trackedFileBackups [ trackingPath ] = { backupFileName : name , version : 1 , backupTime : '2026-10-01T08:00:00.000Z' }
189+ }
190+ lines . push ( JSON . stringify ( {
191+ type : 'file-history-snapshot' , messageId : record . messageId , isSnapshotUpdate : record . update === true ,
192+ snapshot : { messageId : record . messageId , trackedFileBackups, timestamp : '2026-10-01T08:00:00.000Z' } ,
193+ } ) + '\n' )
194+ if ( ! record . update ) {
195+ lines . push ( row ( 'user' , `turn ${ record . messageId } ` , { uuid : record . messageId } ) )
196+ lines . push ( row ( 'assistant' , [ { type : 'text' , text : 'done' } ] ) )
197+ }
198+ }
199+ for ( const [ relativePath , content ] of Object . entries ( current ) ) await writeFile ( join ( workDir , relativePath ) , content )
200+ await writeFile ( join ( configDir , 'projects' , 'fixture' , `${ sessionId } .jsonl` ) , lines . join ( '' ) )
201+ return sessionId
202+ }
203+
204+ async function workspaceChanges ( sessionId : string ) {
205+ const response = await get ( sessionId , 'workspace/status' )
206+ expect ( response . status ) . toBe ( 200 )
207+ const body = await response . json ( ) as { changedFiles : Array < { path : string ; additions : number ; deletions : number } > }
208+ return body . changedFiles
209+ }
210+
211+ test ( 'workspace status lists changes of a session with more snapshots than the old record budget' , async ( ) => {
212+ // Each snapshot repeats every tracked file, so 1200 turns x 30 files is far
213+ // past the former 1000-record / 2 MB snapshot budget.
214+ const prefix = `many-${ crypto . randomUUID ( ) . slice ( 0 , 8 ) } `
215+ const tracked = Object . fromEntries ( Array . from ( { length : 30 } , ( _ , file ) => [ `${ prefix } -${ file } .txt` , `base ${ file } \n` ] ) )
216+ const records = Array . from ( { length : 1200 } , ( ) => ( { messageId : crypto . randomUUID ( ) , backups : tracked } ) )
217+ const sessionId = await createSnapshotSession ( records , {
218+ ...tracked ,
219+ [ `${ prefix } -0.txt` ] : 'base 0\nchanged\n' ,
220+ [ `${ prefix } -1.txt` ] : 'base 1\nchanged\n' ,
221+ } )
222+ const changes = ( await workspaceChanges ( sessionId ) ) . filter ( ( change ) => change . path . startsWith ( prefix ) )
223+ expect ( changes ) . toEqual ( [
224+ expect . objectContaining ( { path : `${ prefix } -0.txt` , additions : 1 , deletions : 0 } ) ,
225+ expect . objectContaining ( { path : `${ prefix } -1.txt` , additions : 1 , deletions : 0 } ) ,
226+ ] )
227+ } , 20_000 )
228+
229+ test ( 'workspace baseline follows the earliest message even when its snapshot is updated later' , async ( ) => {
230+ const path = `update-${ crypto . randomUUID ( ) . slice ( 0 , 8 ) } .txt`
231+ const first = crypto . randomUUID ( )
232+ const second = crypto . randomUUID ( )
233+ const sessionId = await createSnapshotSession ( [
234+ { messageId : first , backups : { } } ,
235+ { messageId : second , backups : { [ path ] : 'two\n' } } ,
236+ // An update replaces the first message's snapshot without moving it.
237+ { messageId : first , backups : { [ path ] : 'one\n' } , update : true } ,
238+ ] , { [ path ] : 'two\nthree\n' } )
239+ const change = ( await workspaceChanges ( sessionId ) ) . find ( ( candidate ) => candidate . path === path )
240+ expect ( change ) . toMatchObject ( { additions : 2 , deletions : 1 } )
241+ } , 20_000 )
242+
243+ test ( 'workspace baseline is reread when a rewind replaces the transcript between passes' , async ( ) => {
244+ const path = `rewound-${ crypto . randomUUID ( ) . slice ( 0 , 8 ) } .txt`
245+ const first = crypto . randomUUID ( )
246+ const sessionId = await createSnapshotSession ( [
247+ { messageId : first , backups : { [ path ] : 'one\n' } } ,
248+ { messageId : crypto . randomUUID ( ) , backups : { [ path ] : 'two\n' } } ,
249+ ] , { [ path ] : 'two\nthree\n' } )
250+ const transcriptPath = join ( configDir , 'projects' , 'fixture' , `${ sessionId } .jsonl` )
251+ const service = sessionService as unknown as { forEachSnapshotLine : ( ...args : unknown [ ] ) => Promise < void > }
252+ const readLines = service . forEachSnapshotLine . bind ( sessionService )
253+ let passes = 0
254+ const spy = spyOn ( service , 'forEachSnapshotLine' ) . mockImplementation ( async ( ...args : unknown [ ] ) => {
255+ await readLines ( ...args )
256+ if ( ++ passes !== 1 ) return
257+ // Like a rewind: write a replacement and rename it over the transcript,
258+ // here with a leading record that shifts every line number.
259+ const replacement = `${ transcriptPath } .rewind.tmp`
260+ await writeFile ( replacement , `${ JSON . stringify ( { type : 'progress' } ) } \n${ await readFile ( transcriptPath , 'utf8' ) } ` )
261+ await rename ( replacement , transcriptPath )
262+ } )
263+ try {
264+ const change = ( await workspaceChanges ( sessionId ) ) . find ( ( candidate ) => candidate . path === path )
265+ expect ( change ) . toMatchObject ( { additions : 2 , deletions : 1 } )
266+ // Two passes over the replaced read, two over the reread.
267+ expect ( passes ) . toBe ( 4 )
268+ } finally { spy . mockRestore ( ) }
126269} , 20_000 )
270+
271+ test ( 'a snapshot-heavy session is charged against the retained budget' , async ( ) => {
272+ // No tool output at all: every byte is snapshots repeating 400 tracked files.
273+ const prefix = `heavy-${ crypto . randomUUID ( ) . slice ( 0 , 8 ) } `
274+ const tracked = Object . fromEntries ( Array . from ( { length : 400 } , ( _ , file ) => [ `${ prefix } -${ file } .txt` , 'same\n' ] ) )
275+ const records = Array . from ( { length : 700 } , ( ) => ( { messageId : crypto . randomUUID ( ) , backups : tracked } ) )
276+ const sessionId = await createSnapshotSession ( records , { } )
277+ await expectPreviewRejected ( sessionId )
278+ } , 20_000 )
279+
280+ test ( 'a cancelled checkpoint listing stops while reading the transcript' , async ( ) => {
281+ const fixture = await createTranscript ( 'cancelled\n' )
282+ const hydrate = spyOn ( sessionService as unknown as { entriesToMessages : ( ) => unknown } , 'entriesToMessages' )
283+ try {
284+ await expect ( listSessionTurnCheckpoints ( fixture . sessionId , AbortSignal . abort ( ) ) ) . rejects . toThrow ( )
285+ expect ( hydrate ) . not . toHaveBeenCalled ( )
286+ } finally { hydrate . mockRestore ( ) }
287+ } )
0 commit comments