diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index 679543ec4..9745ff217 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -53,6 +53,7 @@ export async function executeBashWithOperations( ): Promise { const outputChunks: string[] = []; let outputBytes = 0; + let discardedOutput = false; const maxOutputBytes = DEFAULT_MAX_BYTES * 2; let tempFilePath: string | undefined; @@ -95,6 +96,7 @@ export async function executeBashWithOperations( while (outputBytes > maxOutputBytes && outputChunks.length > 1) { const removed = outputChunks.shift()!; outputBytes -= removed.length; + discardedOutput = true; } // Stream to callback @@ -140,7 +142,7 @@ export async function executeBashWithOperations( output: truncationResult.truncated ? truncationResult.content : fullOutput, exitCode: cancelled ? undefined : (exitCode ?? undefined), cancelled, - truncated: truncationResult.truncated, + truncated: discardedOutput || truncationResult.truncated, fullOutputPath: tempFilePath, }; } diff --git a/packages/coding-agent/test/bash-executor-truncation.test.ts b/packages/coding-agent/test/bash-executor-truncation.test.ts new file mode 100644 index 000000000..7c480fe46 --- /dev/null +++ b/packages/coding-agent/test/bash-executor-truncation.test.ts @@ -0,0 +1,59 @@ +import { readFile, rm } from "node:fs/promises"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { executeBashWithOperations } from "../src/core/bash-executor.ts"; +import { bashExecutionToText } from "../src/core/messages.ts"; + +describe("user bash output truncation (#10164)", () => { + const tempFiles: string[] = []; + + afterEach(async () => { + await Promise.all(tempFiles.splice(0).map((path) => rm(path, { force: true }))); + }); + + // #10164: the retained tail can fit even though earlier output was discarded. + it.each([false, true])("reports discarded chunks when cancelled=%s", async (cancelled) => { + const first = "error: missing build dependency\n".padEnd(64 * 1024, "x"); + const last = "remaining build output\n".padEnd(44 * 1024, "y"); + const controller = new AbortController(); + const result = await executeBashWithOperations( + "cat build.log", + process.cwd(), + { + exec: async (_command, _cwd, { onData }) => { + onData(Buffer.from(first)); + onData(Buffer.from(last)); + if (cancelled) { + controller.abort(); + throw new Error("aborted"); + } + return { exitCode: 0 }; + }, + }, + { signal: controller.signal }, + ); + if (result.fullOutputPath) tempFiles.push(result.fullOutputPath); + // The executor closes its file stream without awaiting its finish event. + await vi.waitFor(async () => { + expect(await readFile(result.fullOutputPath!, "utf-8")).toBe(first + last); + }); + + expect(result.cancelled).toBe(cancelled); + expect(result.output).toBe(last); + expect(result.truncated).toBe(true); + expect( + bashExecutionToText({ role: "bashExecution", command: "cat build.log", timestamp: 0, ...result }), + ).toContain(`[Output truncated. Full output: ${result.fullOutputPath}]`); + }); + + it("does not mark short output as truncated", async () => { + const result = await executeBashWithOperations("echo done", process.cwd(), { + exec: async (_command, _cwd, { onData }) => { + onData(Buffer.from("done\n")); + return { exitCode: 0 }; + }, + }); + expect(result.output).toBe("done\n"); + expect(result.truncated).toBe(false); + expect(result.fullOutputPath).toBeUndefined(); + }); +});