From 50d9f823e414eafb5f4459d16728a2464cd7939f Mon Sep 17 00:00:00 2001 From: "robopipi[bot]" <336283572+robopipi[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:17:49 +0100 Subject: [PATCH] fix(chord): remove whole ANSI escape sequences from tool output (#11607) `sanitizeOutput()` in `@earendil-works/chord/output` deleted the ESC byte (0x1b) along with the other control characters, but it left the rest of each escape sequence in place. Colored tool output such as `\x1b[31mERROR\x1b[0m` came back from `OutputBuffer.snapshot()` as `[31mERROR[0m`. Because the ESC byte was already gone, later ANSI stripping could no longer recognize the leftover fragments. `sanitizeOutput()` now removes whole 7-bit escape sequences before it removes single control characters. It handles: - CSI sequences (colors, cursor and erase commands) - string sequences (OSC, DCS, APC, PM, SOS) terminated by BEL or `ESC \` - short `ESC x` forms such as `ESC ( B` or `ESC 7` `snapshot()` sanitizes the concatenated retained text, so a sequence split across pushed chunks is also removed. **Testing** - Added a regression test in `packages/chord/test/output.test.ts`. It covers SGR colors, cursor and erase CSI, charset and short escapes, OSC 8 hyperlinks and an OSC title, plus an `OutputBuffer` fed a color sequence split across three chunks. The test fails without the change and passes with it. - `test/output.test.ts` and `test/output-skip.test.ts` pass. - Biome is clean. The only `tsc` errors in `npm run check` are in `packages/ai` tests and are unrelated to this change. - The durable package test results are the same with and without the change: 71 test files fail on both, and every individual test that runs passes. - Reproduction from the issue: before the change both lines printed `"[31mERROR[0m\n"`; after it, both print `"ERROR\n"`. Fixes #11604 Requested by @badlogic Co-authored-by: triagent --- packages/chord/src/output.ts | 7 +++++-- packages/chord/test/output.test.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/chord/src/output.ts b/packages/chord/src/output.ts index a31cdfd2a..e0e36a183 100644 --- a/packages/chord/src/output.ts +++ b/packages/chord/src/output.ts @@ -32,13 +32,16 @@ export interface OutputSkip { const NEWLINE = 0x0a; const INVALID_OUTPUT = /[\x00-\x08\x0b-\x1f\ufff9-\ufffb]/g; +/** Whole 7-bit escape sequences: strings (OSC, DCS, APC, PM, SOS) up to BEL or ST, CSI, and short `ESC x` forms. */ +const ESCAPE_SEQUENCE = + /\x1b[\]PX^_][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x20-\x2f]*[\x30-\x7e]/g; const encoder = new TextEncoder(); /** Slices decode exactly: a U+FEFF at a slice's start is text, not a byte-order mark. */ const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); -/** Remove control characters that break display and transcripts; tabs and newlines stay. */ +/** Remove escape sequences and control characters that break display and transcripts; tabs and newlines stay. */ export function sanitizeOutput(text: string): string { - return text.replace(INVALID_OUTPUT, ""); + return text.replace(ESCAPE_SEQUENCE, "").replace(INVALID_OUTPUT, ""); } /** diff --git a/packages/chord/test/output.test.ts b/packages/chord/test/output.test.ts index 00e7d647e..b35a62bf6 100644 --- a/packages/chord/test/output.test.ts +++ b/packages/chord/test/output.test.ts @@ -57,6 +57,18 @@ describe("tool output bounds", () => { expect(sanitizeOutput("a\0b\tc\nd\re\u0007f\ufff9g\ufffbh😀")).toBe("ab\tc\ndefgh😀"); }); + // #11604 + it("removes whole ANSI escape sequences, not only their ESC bytes", () => { + expect(sanitizeOutput("\x1b[31mERROR\x1b[0m\n")).toBe("ERROR\n"); + expect(sanitizeOutput("\x1b[1;38;5;208mx\x1b[?25l\x1b[2K\x1b(By\x1b7z")).toBe("xyz"); + expect(sanitizeOutput("\x1b]8;;https://pi.dev\x07link\x1b]8;;\x1b\\ \x1b]0;title\x07ok")).toBe("link ok"); + const output = new OutputBuffer({ retain: "tail", maxBytes: 1024, maxLines: 100 }); + output.push("\x1b[3"); + output.push("1mERROR\x1b[0"); + output.push("m\n"); + expect(output.snapshot().text).toBe("ERROR\n"); + }); + it("keeps output within the limits unchanged", () => { expect(bound("a\nb\n", head(2))).toEqual({ kept: "a\nb\n", droppedBytes: 0, droppedLines: 0 }); expect(bound("a\nb", tail(2))).toEqual({ kept: "a\nb", droppedBytes: 0, droppedLines: 0 });