fix(coding-agent): report the real size of an oversized last line in the bash truncation notice

fixes #10015
This commit is contained in:
Liam Custer 2026-11-05 13:22:46 -08:00
parent d9af3d73af
commit e33e2abc88
2 changed files with 50 additions and 1 deletions

View file

@ -48,6 +48,7 @@ export class OutputAccumulator {
private completedLines = 0;
private totalLines = 0;
private currentLineBytes = 0;
private lastCompletedLineBytes = 0;
private hasOpenLine = false;
private finished = false;
@ -175,8 +176,9 @@ export class OutputAccumulator {
}
}
/** Bytes of the last line: the open one, or the one the last newline closed. */
getLastLineBytes(): number {
return this.currentLineBytes;
return this.hasOpenLine ? this.currentLineBytes : this.lastCompletedLineBytes;
}
private appendDecodedText(text: string): void {
@ -194,8 +196,10 @@ export class OutputAccumulator {
let newlines = 0;
let lastNewline = -1;
let previousNewline = -1;
for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) {
newlines++;
previousNewline = lastNewline;
lastNewline = i;
}
if (newlines === 0) {
@ -203,6 +207,10 @@ export class OutputAccumulator {
this.hasOpenLine = true;
} else {
this.completedLines += newlines;
// The line the last newline closed began in an earlier chunk when no newline precedes it here.
this.lastCompletedLineBytes =
(previousNewline === -1 ? this.currentLineBytes : 0) +
byteLength(text.slice(previousNewline + 1, lastNewline));
const tail = text.slice(lastNewline + 1);
this.currentLineBytes = byteLength(tail);
this.hasOpenLine = tail.length > 0;

View file

@ -0,0 +1,41 @@
import { rm } from "node:fs/promises";
import { afterEach, describe, expect, it } from "vitest";
import { OutputAccumulator } from "../src/core/tools/output-accumulator.ts";
describe("OutputAccumulator last line size (#10015)", () => {
const tempFiles: string[] = [];
afterEach(async () => {
await Promise.all(tempFiles.splice(0).map((path) => rm(path, { force: true })));
});
async function sizeOf(chunks: string[]): Promise<{ lastLineBytes: number; lastLinePartial: boolean }> {
const output = new OutputAccumulator({ maxBytes: 1024 });
for (const chunk of chunks) output.append(Buffer.from(chunk));
output.finish();
const snapshot = output.snapshot();
await output.closeTempFile();
if (snapshot.fullOutputPath) tempFiles.push(snapshot.fullOutputPath);
return { lastLineBytes: output.getLastLineBytes(), lastLinePartial: snapshot.truncation.lastLinePartial };
}
// #10015: a trailing newline reset the live line counter, so the notice reported the line as 0B.
it("reports the size of an oversized last line that ends with a newline", async () => {
expect(await sizeOf([`${"a".repeat(2000)}\n`])).toEqual({ lastLineBytes: 2000, lastLinePartial: true });
});
it("reports the size of an oversized last line that is still open", async () => {
expect(await sizeOf(["a".repeat(2000)])).toEqual({ lastLineBytes: 2000, lastLinePartial: true });
});
it("sums a line split across chunks", async () => {
expect(await sizeOf(["x\n", "a".repeat(1500), `${"a".repeat(500)}\n`])).toEqual({
lastLineBytes: 2000,
lastLinePartial: true,
});
});
it("measures only the last line when a chunk holds several", async () => {
expect(await sizeOf([`short\n${"a".repeat(2000)}\n`])).toEqual({ lastLineBytes: 2000, lastLinePartial: true });
});
});