From 66c6556771bcdbfbd37496e77f05515e99d1a2dd Mon Sep 17 00:00:00 2001 From: Gangqiang Yao Date: Wed, 4 Nov 2026 17:16:53 +0800 Subject: [PATCH] fix(coding-agent): track discarded user bash output (#10165) Closes #10164. A user `!` command can discard earlier chunks but report `truncated: false` when the retained tail fits within the output limits. The model then receives no truncation notice or full-log path. Track discarded chunks and include that flag in the executor's result for both completion and cancellation. This applies to the current shared finalization path and preserves streaming ANSI handling. Output limits and message format stay unchanged. Related: #2852. Here the complete log is already saved; the incorrect flag hides its path from the model. Validation on main `f1b2e77f5`: - Two deterministic 64 KiB + 44 KiB cases fail without the fix. All three regressions pass with it, checking completion, cancellation, the complete log, model-facing path, and short-output behavior. - Regressions are in `test/bash-executor-truncation.test.ts`; that file and `test/tools.test.ts` pass all 88 tests. - `npm run check`, offline build, and `./test.sh` pass. --- .../coding-agent/src/core/bash-executor.ts | 4 +- .../test/bash-executor-truncation.test.ts | 59 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/bash-executor-truncation.test.ts 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(); + }); +});