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
This commit is contained in:
Peter Steinberger
2026-07-27 16:47:49 -04:00
committed by GitHub
parent 7394262cd1
commit c656e73065
4 changed files with 77 additions and 15 deletions

View File

@@ -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<FixtureLogEntry[]> {
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<FixtureLogEntry>;
cleanup: () => Promise<void>;
}>,
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: {

View File

@@ -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),

View File

@@ -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",

View File

@@ -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<void> {
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,