From 36be9c612e64082a4f0d4d0a94706c38ecbc20a1 Mon Sep 17 00:00:00 2001 From: "robopipi[bot]" <336283572+robopipi[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:47:34 +0200 Subject: [PATCH] fix(coding-agent): decode pi.exec output across UTF-8 chunk boundaries (#11326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `execCommand()` (the function behind `pi.exec()`) decoded each stdout and stderr chunk on its own with `data.toString()`. When a multi-byte UTF-8 character was split across two pipe chunks, each half was turned into U+FFFD, so the result was corrupted. For example, `"你好"` came back as `"���好"` while the command still exited with code 0. This change gives stdout and stderr each their own `StringDecoder("utf8")`. A decoder holds back incomplete bytes until the rest of the character arrives. Both decoders are flushed with `end()` before the promise resolves, on both the success path and the error path. `waitForChildProcess()` can resolve without a stream `end` event, so the decoders are flushed there and not in an `end` handler. The return shape, exit codes, and kill behavior are unchanged. **Testing** - Added a regression test to `packages/coding-agent/test/exec.test.ts`. The child process splits `"你好"` across two writes on both stdout and stderr. The test fails without the fix and passes with it: `node ../../node_modules/vitest/dist/cli.js --run test/exec.test.ts` reports 3 passed. - `npm run check` passes. - Reproduction from the issue: a child that writes the first byte of `"你好"` and then the rest 100 ms later. Before the fix the result was `{"stdout":"���好",...}`. After the fix it is `{"stdout":"你好","stderr":"","code":0,"killed":false}`. Fixes #11312 Requested by @davidbrai --------- Co-authored-by: triagent Co-authored-by: David Brailovsky --- packages/coding-agent/src/core/exec.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index 53e53366a..cf4205f7f 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -3,6 +3,7 @@ */ import { spawn } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; import { waitForChildProcess } from "../utils/child-process.ts"; /** @@ -46,6 +47,10 @@ export async function execCommand( let stdout = ""; let stderr = ""; + // Decode across chunks: a multi-byte UTF-8 character can be split between two chunks, + // and decoding each chunk separately would corrupt it (earendil-works/pi#11312). + const stdoutDecoder = new StringDecoder("utf8"); + const stderrDecoder = new StringDecoder("utf8"); let killed = false; let timeoutId: NodeJS.Timeout | undefined; @@ -79,11 +84,11 @@ export async function execCommand( } proc.stdout?.on("data", (data) => { - stdout += data.toString(); + stdout += stdoutDecoder.write(data); }); proc.stderr?.on("data", (data) => { - stderr += data.toString(); + stderr += stderrDecoder.write(data); }); // Wait for process termination without hanging on inherited stdio handles @@ -91,6 +96,8 @@ export async function execCommand( waitForChildProcess(proc) .then((code) => { if (timeoutId) clearTimeout(timeoutId); + stdout += stdoutDecoder.end(); + stderr += stderrDecoder.end(); if (options?.signal) { options.signal.removeEventListener("abort", killProcess); } @@ -98,6 +105,8 @@ export async function execCommand( }) .catch((error: Error) => { if (timeoutId) clearTimeout(timeoutId); + stdout += stdoutDecoder.end(); + stderr += stderrDecoder.end(); if (options?.signal) { options.signal.removeEventListener("abort", killProcess); }