diff --git a/src/agents/sessions/exec.test.ts b/src/agents/sessions/exec.test.ts index 1a991e257bce..af5d3644f06a 100644 --- a/src/agents/sessions/exec.test.ts +++ b/src/agents/sessions/exec.test.ts @@ -93,6 +93,44 @@ describe("execCommand", () => { expect(result.stdoutTruncatedChars).toBe(3); }); + it("preserves UTF-8 characters split across stdout and stderr chunks", async () => { + const child = createStubChild(); + const wait = createDeferred(); + spawnMock.mockReturnValue(child); + waitForChildProcessMock.mockReturnValue(wait.promise); + const { execCommand } = await import("./exec.js"); + + const resultPromise = execCommand("cmd", [], "/tmp"); + const stdout = Buffer.from("stdout-😀-complete", "utf8"); + const stderr = Buffer.from("stderr-😀-complete", "utf8"); + child.stdout.emit("data", stdout.subarray(0, 9)); + child.stderr.emit("data", stderr.subarray(0, 9)); + child.stdout.emit("data", stdout.subarray(9)); + child.stderr.emit("data", stderr.subarray(9)); + wait.resolve(0); + + const result = await resultPromise; + expect(result.stdout).toBe("stdout-😀-complete"); + expect(result.stderr).toBe("stderr-😀-complete"); + }); + + it("flushes incomplete UTF-8 sequences when the process exits", async () => { + const child = createStubChild(); + const wait = createDeferred(); + spawnMock.mockReturnValue(child); + waitForChildProcessMock.mockReturnValue(wait.promise); + const { execCommand } = await import("./exec.js"); + + const resultPromise = execCommand("cmd", [], "/tmp"); + child.stdout.emit("data", Buffer.from([0xe2, 0x82])); + child.stderr.emit("data", Buffer.from([0xf0, 0x9f, 0x98])); + wait.resolve(0); + + const result = await resultPromise; + expect(result.stdout).toBe("�"); + expect(result.stderr).toBe("�"); + }); + it("fails instead of silently truncating default exec output", async () => { const child = createStubChild(); const wait = createDeferred(); diff --git a/src/agents/sessions/exec.ts b/src/agents/sessions/exec.ts index 2adc98ec50e9..e5947b374d1c 100644 --- a/src/agents/sessions/exec.ts +++ b/src/agents/sessions/exec.ts @@ -3,6 +3,7 @@ */ import { spawn } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; import { waitForChildProcess } from "../utils/child-process.js"; const DEFAULT_OUTPUT_LIMIT_CHARS = 16 * 1024 * 1024; @@ -40,6 +41,10 @@ type OutputCapture = { truncatedChars: number; }; +function decodeCapturedOutput(decoder: StringDecoder, chunk: Buffer | string): string { + return Buffer.isBuffer(chunk) ? decoder.write(chunk) : `${decoder.end()}${chunk}`; +} + function clampMaxOutputChars(value: number | undefined): number { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { return DEFAULT_OUTPUT_LIMIT_CHARS; @@ -87,6 +92,8 @@ export async function execCommand( let stdout: OutputCapture = { text: "", truncatedChars: 0 }; let stderr: OutputCapture = { text: "", truncatedChars: 0 }; + const stdoutDecoder = new StringDecoder("utf8"); + const stderrDecoder = new StringDecoder("utf8"); let killed = false; let timeoutId: NodeJS.Timeout | undefined; let forceKillTimer: NodeJS.Timeout | undefined; @@ -114,6 +121,16 @@ export async function execCommand( if (options?.signal) { options.signal.removeEventListener("abort", killProcess); } + const stdoutBeforeFlush = stdout.truncatedChars; + stdout = appendCapturedOutput(stdout, stdoutDecoder.end(), maxOutputChars, truncateOutput); + if (!truncateOutput && stdout.truncatedChars > stdoutBeforeFlush && !outputLimitExceeded) { + outputLimitExceeded = "stdout"; + } + const stderrBeforeFlush = stderr.truncatedChars; + stderr = appendCapturedOutput(stderr, stderrDecoder.end(), maxOutputChars, truncateOutput); + if (!truncateOutput && stderr.truncatedChars > stderrBeforeFlush && !outputLimitExceeded) { + outputLimitExceeded = "stderr"; + } if (outputLimitExceeded) { stderr = appendCapturedOutput( stderr, @@ -164,7 +181,12 @@ export async function execCommand( proc.stdout?.on("data", (data) => { const before = stdout.truncatedChars; - stdout = appendCapturedOutput(stdout, data, maxOutputChars, truncateOutput); + stdout = appendCapturedOutput( + stdout, + decodeCapturedOutput(stdoutDecoder, data), + maxOutputChars, + truncateOutput, + ); if (stdout.truncatedChars > before) { markOutputLimitExceeded("stdout"); } @@ -172,7 +194,12 @@ export async function execCommand( proc.stderr?.on("data", (data) => { const before = stderr.truncatedChars; - stderr = appendCapturedOutput(stderr, data, maxOutputChars, truncateOutput); + stderr = appendCapturedOutput( + stderr, + decodeCapturedOutput(stderrDecoder, data), + maxOutputChars, + truncateOutput, + ); if (stderr.truncatedChars > before) { markOutputLimitExceeded("stderr"); }