fix(coding-agent): stream bash output incrementally (#4165)

fixes #4145
This commit is contained in:
Armin Ronacher
2026-05-04 19:06:07 +02:00
committed by GitHub
parent 3cd1554c8f
commit 6b18cdbac1
3 changed files with 399 additions and 119 deletions

View File

@@ -3,7 +3,7 @@ import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executeBashWithOperations } from "../src/core/bash-executor.js";
import { createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";
import { type BashOperations, createBashTool, createLocalBashOperations } from "../src/core/tools/bash.js";
import { computeEditsDiff } from "../src/core/tools/edit-diff.js";
import {
createEditTool,
@@ -449,6 +449,42 @@ describe("Coding Agent Tools", () => {
);
});
it("should include full output path for truncated timeout and abort errors", async () => {
for (const testCase of [
{ error: "timeout:5", expected: "Command timed out after 5 seconds" },
{ error: "aborted", expected: "Command aborted" },
]) {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
for (let i = 1; i <= 3000; i++) {
onData(Buffer.from(`${i}\n`, "utf-8"));
}
throw new Error(testCase.error);
},
};
const bash = createBashTool(testDir, { operations });
let error: unknown;
try {
await bash.execute(`test-call-${testCase.error}`, { command: "chatty-fail" });
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toContain(testCase.expected);
expect(message).toMatch(/\[Showing lines \d+-\d+ of \d+\. Full output: /);
expect(message).not.toContain("Full output: undefined");
const fullOutputPath = message.match(/Full output: ([^\]\n]+)/)?.[1];
expect(fullOutputPath).toBeDefined();
expect(existsSync(fullOutputPath!)).toBe(true);
const fullOutput = readFileSync(fullOutputPath!, "utf-8");
expect(fullOutput).toContain("1\n2\n3");
expect(fullOutput).toContain("2998\n2999\n3000");
}
});
it("should throw error when cwd does not exist", async () => {
const nonexistentCwd = "/this/directory/definitely/does/not/exist/12345";
@@ -517,6 +553,42 @@ describe("Coding Agent Tools", () => {
expect(getTextOutput(result).trim()).toBe("no-prefix");
});
it("should coalesce streaming updates for chatty output", async () => {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
for (let i = 0; i < 5000; i++) {
onData(Buffer.from(`line ${i}\n`, "utf-8"));
}
return { exitCode: 0 };
},
};
const updates: Array<{ content: Array<{ type: string; text?: string }>; details?: unknown }> = [];
const bash = createBashTool(testDir, { operations });
const result = await bash.execute("test-call-chatty-updates", { command: "chatty" }, undefined, (update) =>
updates.push(update),
);
expect(updates.length).toBeLessThan(25);
expect(getTextOutput(result)).toContain("line 4999");
});
it("should decode UTF-8 characters split across output chunks", async () => {
const euro = Buffer.from("€\n", "utf-8");
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
onData(euro.subarray(0, 1));
onData(euro.subarray(1));
return { exitCode: 0 };
},
};
const bash = createBashTool(testDir, { operations });
const result = await bash.execute("test-call-split-utf8", { command: "split-utf8" });
expect(getTextOutput(result).trim()).toBe("€");
});
it("should expose local bash operations for extension reuse", async () => {
const ops = createLocalBashOperations();
const chunks: Buffer[] = [];