From c656e73065a35279755d4e1cba167d8e67d80280 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 16:47:49 -0400 Subject: [PATCH] fix(tui): preserve Unicode during fragmented terminal input [AI-assisted] (#114728) * fix(tests): run Codex prewarm in exactly one shard * fix(tui): preserve Unicode in fragmented terminal input * fix(tui): keep Unicode stress coverage within lint limits --- .../tui-pty-harness-fixture-test-support.ts | 33 +++++++++++++++++++ src/tui/tui-pty-harness.e2e.test.ts | 16 ++++----- src/tui/tui-pty-test-support.test.ts | 27 +++++++++++++++ src/tui/tui-pty-test-support.ts | 16 +++++---- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/tui/tui-pty-harness-fixture-test-support.ts b/src/tui/tui-pty-harness-fixture-test-support.ts index cea90ec1d227..cc67ff1787e1 100644 --- a/src/tui/tui-pty-harness-fixture-test-support.ts +++ b/src/tui/tui-pty-harness-fixture-test-support.ts @@ -7,6 +7,13 @@ export type FixtureLogEntry = { payload?: unknown; }; +export const COMPACT_TERMINAL_SIZES = [ + [64, 18], + [68, 18], + [72, 20], + [80, 20], +] as const; + export async function readFixtureLog(logPath: string): Promise { try { const text = await readFile(logPath, "utf8"); @@ -53,6 +60,32 @@ export function objectFieldEquals(entry: FixtureLogEntry, field: string, value: return Object.hasOwn(payload, field) && payload[field] === value; } +/** Proves fixture-local fragmentation preserves a Unicode prompt through the real TUI loop. */ +export async function exerciseFragmentedUnicodePrompt( + startFixture: (opts: { env?: NodeJS.ProcessEnv }) => Promise<{ + run: PtyRun; + waitForLogEntry: (predicate: (entry: FixtureLogEntry) => boolean) => Promise; + cleanup: () => Promise; + }>, + startupTimeoutMs: number, +) { + const fixture = await startFixture({ + env: { OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE: "1", OPENCLAW_TUI_PTY_TYPE_DELAY_MS: "1" }, + }); + const message = "hello 👋 from pty"; + + try { + await fixture.run.waitForOutput("local ready", startupTimeoutMs); + await fixture.run.write(`${message}\r`); + await fixture.run.waitForOutput(`PTY_RESPONSE: ${message}`); + await fixture.waitForLogEntry( + (entry) => entry.method === "sendChat" && objectFieldEquals(entry, "message", message), + ); + } finally { + await fixture.cleanup(); + } +} + /** Approves a workspace skill using exact fragments that survive narrow-terminal wrapping. */ export async function approveWorkspaceSkill( fixture: { diff --git a/src/tui/tui-pty-harness.e2e.test.ts b/src/tui/tui-pty-harness.e2e.test.ts index 4f43400f6875..2b24ac4f41e2 100644 --- a/src/tui/tui-pty-harness.e2e.test.ts +++ b/src/tui/tui-pty-harness.e2e.test.ts @@ -7,6 +7,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { approveWorkspaceSkill, buildOpaqueSessionIsolationFixture, + COMPACT_TERMINAL_SIZES, + exerciseFragmentedUnicodePrompt, objectFieldEquals, readFixtureLog, waitForFixtureLogEntry, @@ -655,7 +657,7 @@ describe.sequential("TUI PTY harness", () => { }); it( - "drives the real TUI terminal loop through typed input", + "drives the real TUI terminal loop through typed and fragmented Unicode input", async () => { await fixture.run.write("hello from pty\r"); await fixture.run.waitForOutput("PTY_RESPONSE: hello from pty"); @@ -663,8 +665,9 @@ describe.sequential("TUI PTY harness", () => { (entry) => entry.method === "sendChat" && objectFieldEquals(entry, "message", "hello from pty"), ); + await exerciseFragmentedUnicodePrompt(startTuiFixture, STARTUP_TIMEOUT_MS); }, - TEST_TIMEOUT_MS, + STARTUP_TEST_TIMEOUT_MS, ); it( @@ -707,12 +710,9 @@ describe.sequential("TUI PTY harness", () => { TEST_TIMEOUT_MS, ); - it.each([ - { cols: 64, rows: 18 }, - { cols: 72, rows: 20 }, - ])( - "presents and resolves workspace skill approval in a $cols×$rows terminal", - async ({ cols, rows }) => { + it.each(COMPACT_TERMINAL_SIZES)( + "presents and resolves workspace skill approval in a %i×%i terminal", + async (cols, rows) => { const compactFixture = await startTuiFixture({ env: { OPENCLAW_TUI_PTY_COLS: String(cols), diff --git a/src/tui/tui-pty-test-support.test.ts b/src/tui/tui-pty-test-support.test.ts index 44d110438b09..b5fe0c0dc64d 100644 --- a/src/tui/tui-pty-test-support.test.ts +++ b/src/tui/tui-pty-test-support.test.ts @@ -71,6 +71,33 @@ describe("TUI PTY test support", () => { ); }); + it.each([ + { chunkSize: "1", chunks: ["A", "👋", "B"] }, + { chunkSize: "2", chunks: ["A👋", "B"] }, + ])( + "preserves Unicode characters with fixture-specific $chunkSize-character input chunks", + async ({ chunkSize, chunks }) => { + const write = vi.fn(); + const pty = createMockPty({ write }); + nodePtyMocks.spawn.mockReturnValue(pty); + + const run = startPty("node", [], { + cwd: process.cwd(), + env: { + OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE: chunkSize, + OPENCLAW_TUI_PTY_TYPE_DELAY_MS: "1", + }, + exitTimeoutMs: 1_000, + outputTimeoutMs: 1_000, + }); + + await run.write("A👋B"); + + expect(write).toHaveBeenCalledTimes(chunks.length); + expect(write.mock.calls.map(([chunk]) => chunk)).toEqual(chunks); + }, + ); + it.each([ { label: "split ANSI control sequences", diff --git a/src/tui/tui-pty-test-support.ts b/src/tui/tui-pty-test-support.ts index 9dc314078189..93a258226a89 100644 --- a/src/tui/tui-pty-test-support.ts +++ b/src/tui/tui-pty-test-support.ts @@ -69,18 +69,20 @@ function readPtyDimensionEnv(name: string, fallback: number, env: NodeJS.Process async function writePtyInput( pty: IPty, data: string, + env: NodeJS.ProcessEnv, opts: { delay?: boolean } = {}, ): Promise { - const delayMs = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_DELAY_MS"); + const delayMs = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_DELAY_MS", env); if (!delayMs || opts.delay === false) { pty.write(data); return; } - const chunkSize = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE") ?? 1; - // Chunked writes reproduce paste/type races without making every PTY test slow by default. - for (let idx = 0; idx < data.length; idx += chunkSize) { - pty.write(data.slice(idx, idx + chunkSize)); - if (idx + chunkSize < data.length) { + const chunkSize = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE", env) ?? 1; + // Chunk by Unicode characters so stress typing never sends half of a surrogate pair. + const characters = Array.from(data); + for (let idx = 0; idx < characters.length; idx += chunkSize) { + pty.write(characters.slice(idx, idx + chunkSize).join("")); + if (idx + chunkSize < characters.length) { await sleep(delayMs); } } @@ -149,7 +151,7 @@ export function startPty( const run: PtyRun = { output: () => output, visibleOutput: () => visibleOutput, - write: async (data, writeOpts) => await writePtyInput(pty, data, writeOpts), + write: async (data, writeOpts) => await writePtyInput(pty, data, ptyEnv, writeOpts), waitForOutput: async (needle, timeoutMs = opts.outputTimeoutMs) => await waitFor({ timeoutMs,