From f953067814ceed5fc9366db577333c460c9c5e4f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 21 May 2026 16:54:56 +0200 Subject: [PATCH] fix(coding-agent): correct bash truncation line count closes #4818 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/tools/output-accumulator.ts | 12 +++++++--- .../coding-agent/src/core/tools/truncate.ts | 15 +++++++++++-- packages/coding-agent/test/tools.test.ts | 22 +++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5d14b69a..611f82e2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Fixed final bash tool cards to avoid rendering duplicate full-output truncation paths ([#4819](https://github.com/earendil-works/pi/issues/4819)). +- Fixed bash tool truncation line counts to ignore the trailing newline as an extra output line ([#4818](https://github.com/earendil-works/pi/issues/4818)). ## [0.75.4] - 2026-05-20 diff --git a/packages/coding-agent/src/core/tools/output-accumulator.ts b/packages/coding-agent/src/core/tools/output-accumulator.ts index 78fcfe9e..e13fe458 100644 --- a/packages/coding-agent/src/core/tools/output-accumulator.ts +++ b/packages/coding-agent/src/core/tools/output-accumulator.ts @@ -45,8 +45,10 @@ export class OutputAccumulator { private tailStartsAtLineBoundary = true; private totalRawBytes = 0; private totalDecodedBytes = 0; - private totalLines = 1; + private completedLines = 0; + private totalLines = 0; private currentLineBytes = 0; + private hasOpenLine = false; private finished = false; private tempFilePath: string | undefined; @@ -164,10 +166,14 @@ export class OutputAccumulator { } if (newlines === 0) { this.currentLineBytes += bytes; + this.hasOpenLine = true; } else { - this.totalLines += newlines; - this.currentLineBytes = byteLength(text.slice(lastNewline + 1)); + this.completedLines += newlines; + const tail = text.slice(lastNewline + 1); + this.currentLineBytes = byteLength(tail); + this.hasOpenLine = tail.length > 0; } + this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0); } private trimTail(): void { diff --git a/packages/coding-agent/src/core/tools/truncate.ts b/packages/coding-agent/src/core/tools/truncate.ts index 18ac5d74..c638ee48 100644 --- a/packages/coding-agent/src/core/tools/truncate.ts +++ b/packages/coding-agent/src/core/tools/truncate.ts @@ -44,6 +44,17 @@ export interface TruncationOptions { maxBytes?: number; } +function splitLinesForCounting(content: string): string[] { + if (content.length === 0) { + return []; + } + const lines = content.split("\n"); + if (content.endsWith("\n")) { + lines.pop(); + } + return lines; +} + /** * Format bytes as human-readable size. */ @@ -69,7 +80,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}): const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const totalBytes = Buffer.byteLength(content, "utf-8"); - const lines = content.split("\n"); + const lines = splitLinesForCounting(content); const totalLines = lines.length; // Check if no truncation needed @@ -159,7 +170,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}): const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; const totalBytes = Buffer.byteLength(content, "utf-8"); - const lines = content.split("\n"); + const lines = splitLinesForCounting(content); const totalLines = lines.length; // Check if no truncation needed diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 1d850e49..c5e8fa6f 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -580,6 +580,28 @@ describe("Coding Agent Tools", () => { expect(getTextOutput(result)).toContain("line 4999"); }); + it("should not count a trailing newline as an extra truncated bash output line", async () => { + const operations: BashOperations = { + exec: async (_command, _cwd, { onData }) => { + for (let i = 1; i <= 4000; i++) { + onData(Buffer.from(`line-${String(i).padStart(4, "0")}\n`, "utf-8")); + } + return { exitCode: 0 }; + }, + }; + const bash = createBashTool(testDir, { operations }); + + const result = await bash.execute("test-call-trailing-newline-line-count", { command: "many-lines" }); + const output = getTextOutput(result); + + expect(result.details?.truncation?.totalLines).toBe(4000); + expect(result.details?.truncation?.outputLines).toBe(2000); + expect(output).toContain("line-2001"); + expect(output).toContain("line-4000"); + expect(output).toMatch(/\[Showing lines 2001-4000 of 4000\. Full output: /); + expect(output).not.toContain("4001"); + }); + it("should decode UTF-8 characters split across output chunks", async () => { const euro = Buffer.from("€\n", "utf-8"); const operations: BashOperations = {