fix(tools): preserve complete bounded presentations

This commit is contained in:
Tianyi Cui
2026-07-22 00:55:59 +08:00
parent 0dfd9545a6
commit 63d48593cc
9 changed files with 52 additions and 23 deletions

View File

@@ -37,9 +37,9 @@ export interface FileTextLine {
export interface WindowResult {
/** Returned lines, already numbered. */
lines: FileTextLine[]
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
/** Exact total line count in the file. */
totalLines: number
/** Whether selected output hit the byte cap before EOF or the requested limit. */
/** Whether selected output hit the byte cap. */
truncatedByBytes: boolean
}
@@ -49,9 +49,9 @@ export interface FileReadOutcome {
offset: number
/** Returned lines, already numbered. */
lines: FileTextLine[]
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
/** Exact total line count in the file. */
totalLines: number
/** Whether selected output hit the byte cap before EOF or the requested limit. */
/** Whether selected output hit the byte cap. */
truncatedByBytes?: true
}
@@ -60,11 +60,10 @@ interface WindowAccumulator {
totalLines: number
outputBytes: number
truncatedByBytes: boolean
done: boolean
}
function newAccumulator(): WindowAccumulator {
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false }
}
function truncateLine(line: string, maxLineLength: number): string {
@@ -77,13 +76,12 @@ function lineByteSize(line: string, currentLineCount: number): number {
function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
acc.totalLines += 1
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
if (acc.truncatedByBytes || acc.totalLines < request.offset || acc.lines.length >= request.limit) return
const text = truncateLine(rawLine, request.maxLineLength)
const bytes = lineByteSize(text, acc.lines.length)
if (acc.outputBytes + bytes > request.maxBytes) {
acc.truncatedByBytes = true
acc.done = true
return
}
acc.outputBytes += bytes
@@ -102,8 +100,9 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string
}
/**
* Build one window from streamed or whole-file chunks, enforcing line and byte caps and throwing
* `FS_NOT_FOUND` when the requested offset is past EOF.
* Build one window from streamed or whole-file chunks, enforcing line and byte caps while still
* scanning to an exact total line count, and throwing `FS_NOT_FOUND` when the requested offset is
* past EOF.
* @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning.
* @param request - the resolved window; the caller has already applied its defaults and caps.
* @param displayPath - the caller-facing path used in the offset-out-of-range error.
@@ -137,7 +136,6 @@ export async function buildWindow(
appendToLineBuffer(chunk.slice(startPos, newlinePos))
flushLine()
startPos = newlinePos + 1
if (acc.done) return finish(acc, request, displayPath)
}
appendToLineBuffer(chunk.slice(startPos))
}

View File

@@ -86,6 +86,7 @@ describe('buildWindow', () => {
it('caps output at a custom maxBytes', async () => {
const result = await buildWindow(whole('aaaa\nbbbb\ncccc'), { offset: 1, limit: 10, maxLineLength: 2000, maxBytes: 9 }, 'f')
expect(result.lines.map(l => l.text)).toEqual(['aaaa', 'bbbb'])
expect(result.totalLines).toBe(3)
expect(result.truncatedByBytes).toBe(true)
})
})
@@ -105,6 +106,7 @@ describe('buildWindow', () => {
it('caps output bytes mid-stream', async () => {
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
const result = await buildWindow(chunked(big, 512), READ_ALL, 'f')
expect(result.totalLines).toBe(2000)
expect(result.truncatedByBytes).toBe(true)
})

View File

@@ -572,6 +572,9 @@ describe('read caps are plugin config', () => {
const { ctx, fs } = await setupWith({ readMaxBytes: 9 })
fs.files.set('key:a.txt', 'aaaa\nbbbb\ncccc')
const result = await call(ctx, 'read', { file_path: 'a.txt' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected read success')
expect(result.value).toMatchObject({ totalLines: 3 })
expect(text(result)).toContain('Output capped.')
expect(text(result)).not.toContain('cccc')
})