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 <triagent@localhost>
This commit is contained in:
robopipi[bot] 2026-10-30 12:17:49 +01:00 • committed by GitHub
parent 102d128713
commit 50d9f823e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 17 additions and 2 deletions

View file

@ -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, "");
}
/**

View file

@ -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 });