Skip to content

Commit 0894dc8

Browse files
committed
fix(server): keep turn change previews working on long sessions
Long sessions lost their per-turn change cards: transcripts over 16 MiB were refused outright, and smaller ones could take minutes because every turn re-read and re-canonicalized every tracked file. - Index the next snapshot once per turn instead of matching each tracked file against all of it (O(files^2) realpath calls per turn), and skip files whose two boundaries share one backup. Output is unchanged. - Parse checkpoint transcripts without tool output bodies and budget the source (256 MiB) and what stays resident, including file-history snapshots (24 MiB), instead of the raw 16 MiB size. Reads stop on abort. - Treat an unreadable before-backup (e.g. after the 30-day file-history cleanup) as unknown instead of absent, so turns no longer report every tracked file as created and fall back to recorded edits. - Reduce workspace file-history to one baseline per path in two streamed passes, reread when a rewind replaces the transcript in between, and keep git status when session evidence exceeds its viewing budget.
1 parent c70b4c5 commit 0894dc8

8 files changed

Lines changed: 795 additions & 81 deletions

‎src/server/__tests__/session-messages-http.test.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,16 +137,20 @@ afterEach(async () => {
137137
})
138138

139139
describe('session messages HTTP surface', () => {
140-
it('keeps a transcript above 20 MiB readable when checkpoint previews exceed their budget (#1373)', async () => {
140+
it('keeps a transcript readable when checkpoint previews exceed their budget (#1373)', async () => {
141141
const sessionId = await seedSessionWithSubagent()
142142
const filePath = path.join(tmpDir, 'projects', '-tmp-http-invariant', `${sessionId}.jsonl`)
143143
const content = 'x'.repeat(8 * 1024)
144144
const count = 2700
145+
// Unlike tool output, checkpoints retain every other record while parsing.
146+
await fs.appendFile(filePath, Array.from({ length: 400 }, () => JSON.stringify({
147+
type: 'progress', padding: content,
148+
})).join('\n') + '\n')
145149
await fs.appendFile(filePath, Array.from({ length: count }, (_, n) => JSON.stringify({
146150
type: 'assistant', uuid: `issue-1373-${n}`, timestamp: '2026-01-02T00:00:00Z',
147151
message: { role: 'assistant', content },
148152
})).join('\n') + '\n')
149-
expect((await fs.stat(filePath)).size).toBeGreaterThan(20 * 1024 * 1024)
153+
expect((await fs.stat(filePath)).size).toBeGreaterThan(24 * 1024 * 1024)
150154

151155
const checkpoint = await api('GET', `/api/sessions/${sessionId}/turn-checkpoints`)
152156
expect(checkpoint.status).toBe(413)
@@ -193,14 +197,16 @@ describe('session messages HTTP surface', () => {
193197
it('rejects oversized automatic checkpoint previews before canonical history loading', async () => {
194198
const sessionId = await seedSessionWithSubagent()
195199
const filePath = path.join(tmpDir, 'projects', '-tmp-http-invariant', `${sessionId}.jsonl`)
196-
await fs.truncate(filePath, 17 * 1024 * 1024)
197-
const canonical = spyOn(sessionService, 'getSessionMessagesWithEvidence').mockImplementation(() => { throw new Error('full history must not be hydrated') })
200+
// Sparse and newline-free: reading it would build one 256 MiB line.
201+
await fs.truncate(filePath, 256 * 1024 * 1024 + 1)
202+
const hydrate = spyOn(sessionService as unknown as { entriesToMessages: () => unknown }, 'entriesToMessages')
203+
.mockImplementation(() => { throw new Error('full history must not be hydrated') })
198204
try {
199205
const response = await api('GET', `/api/sessions/${sessionId}/turn-checkpoints`)
200206
expect(response.status).toBe(413)
201207
expect((await response.json() as { error: string }).error).toBe('HISTORY_CHECKPOINT_PREVIEW_LIMIT')
202-
expect(canonical).not.toHaveBeenCalled()
203-
} finally { canonical.mockRestore() }
208+
expect(hydrate).not.toHaveBeenCalled()
209+
} finally { hydrate.mockRestore() }
204210
})
205211

206212
it('returns the whole bounded transcript in one response for mode=full', async () => {

‎src/server/__tests__/workspace-service.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,22 @@ describe('WorkspaceService', () => {
208208
await expect(snapshots.getStatus('session')).rejects.toBe(failure)
209209
})
210210

211+
it('keeps authoritative git status when bounded session evidence exceeds its budget', async () => {
212+
const repoDir = await createGitWorkspace()
213+
const failure = new ApiError(413, 'History viewing budget exceeded', 'HISTORY_WORKSPACE_LIMIT')
214+
const services = [
215+
new WorkspaceService(async () => repoDir, async () => { throw failure }),
216+
new WorkspaceService(async () => repoDir, async () => [], async () => { throw failure }),
217+
]
218+
for (const service of services) {
219+
const result = await service.getStatus('session')
220+
expect(result.state).toBe('ok')
221+
expect(result.changedFiles.map((file) => file.path).sort()).toEqual(
222+
['deleted.txt', 'new.txt', 'tracked.txt', 'untracked.txt'],
223+
)
224+
}
225+
})
226+
211227
it('returns git status for modified, added, deleted, and untracked files', async () => {
212228
const repoDir = await createGitWorkspace()
213229
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null)

‎src/server/api/sessions.checkpointBudget.test.ts‎

Lines changed: 177 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
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'
33
import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
55
import { 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
810
const originalEnvironment = { ...process.env }
911
let sandbox: string
1012
let workDir: string
1113
let configDir: string
1214
let 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

1418
beforeAll(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

2834
afterAll(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

81101
test('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

101122
test('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+
})

‎src/server/api/sessions.ts‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1655,15 +1655,7 @@ async function branchSession(req: Request, sessionId: string): Promise<Response>
16551655
}
16561656
}
16571657

1658-
async function assertCheckpointPreviewBudget(sessionId: string): Promise<void> {
1659-
const found = await sessionService.findSessionFile(sessionId)
1660-
if (found && Bun.file(found.filePath).size > 16 * 1024 * 1024) {
1661-
throw new ApiError(413, 'This transcript exceeds the full checkpoint preview budget. Chat history remains available in pages.', 'HISTORY_CHECKPOINT_PREVIEW_LIMIT')
1662-
}
1663-
}
1664-
16651658
async function getTurnCheckpoints(req: Request, sessionId: string): Promise<Response> {
1666-
await assertCheckpointPreviewBudget(sessionId)
16671659
const checkpoints = await listSessionTurnCheckpoints(sessionId, req.signal, new URL(req.url).searchParams.get('frozen') === 'true')
16681660
// Make this turn's real changed files previewable even when they live outside
16691661
// the session workdir (e.g. the user told the model to write to an absolute
@@ -1677,7 +1669,6 @@ async function getTurnCheckpoints(req: Request, sessionId: string): Promise<Resp
16771669
}
16781670

16791671
async function getTurnCheckpointDiff(sessionId: string, url: URL): Promise<Response> {
1680-
await assertCheckpointPreviewBudget(sessionId)
16811672
const targetUserMessageId = url.searchParams.get('targetUserMessageId') || undefined
16821673
const userMessageIndexParam = url.searchParams.get('userMessageIndex')
16831674
const path = url.searchParams.get('path')

0 commit comments

Comments
 (0)