Files
openclaw/src/agents/bash-process-registry.test.ts
Liu Yuan 419824729a fix: fail loud when PTY cursor mode is unknown (#51490) (thanks @liuy)
* fix(process): auto-detect PTY cursor key mode for send-keys

When a PTY session sends smkx (\x1b[?1h) or rmkx (\x1b[?1l) to switch
cursor key mode, send-keys now detects this and encodes cursor keys
accordingly.

- smkx/rmkx detection in handleStdout before sanitizeBinaryOutput
- cursorKeyMode stored in ProcessSession
- encodeKeySequence accepts cursorKeyMode parameter
- DECCKM_SS3_KEYS for application mode (arrows + home/end)
- CSI sequences for normal mode
- Modified keys (including alt) always use xterm modifier scheme
- Extract detectCursorKeyMode for unit testing
- Use lastIndexOf to find last toggle in chunk (later one wins)

Fixes #51488

* fix: fail loud when PTY cursor mode is unknown (#51490) (thanks @liuy)

* style: format process send-keys guard (#51490) (thanks @liuy)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-25 15:51:27 +05:30

174 lines
5.2 KiB
TypeScript

import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ProcessSession } from "./bash-process-registry.js";
import {
addSession,
appendOutput,
drainSession,
listFinishedSessions,
markBackgrounded,
markExited,
resetProcessRegistryForTests,
} from "./bash-process-registry.js";
import { createProcessSessionFixture } from "./bash-process-registry.test-helpers.js";
describe("bash process registry", () => {
function createRegistrySession(params: {
id?: string;
maxOutputChars: number;
pendingMaxOutputChars: number;
backgrounded: boolean;
}): ProcessSession {
return createProcessSessionFixture({
id: params.id ?? "sess",
command: "echo test",
child: { pid: 123, removeAllListeners: vi.fn() } as unknown as ChildProcessWithoutNullStreams,
maxOutputChars: params.maxOutputChars,
pendingMaxOutputChars: params.pendingMaxOutputChars,
backgrounded: params.backgrounded,
});
}
beforeEach(() => {
resetProcessRegistryForTests();
});
it("captures output and truncates", () => {
const session = createRegistrySession({
maxOutputChars: 10,
pendingMaxOutputChars: 30_000,
backgrounded: false,
});
addSession(session);
appendOutput(session, "stdout", "0123456789");
appendOutput(session, "stdout", "abcdef");
expect(session.aggregated).toBe("6789abcdef");
expect(session.truncated).toBe(true);
});
it("caps pending output to avoid runaway polls", () => {
const session = createRegistrySession({
maxOutputChars: 100_000,
pendingMaxOutputChars: 20_000,
backgrounded: true,
});
addSession(session);
const payload = `${"a".repeat(70_000)}${"b".repeat(20_000)}`;
appendOutput(session, "stdout", payload);
const drained = drainSession(session);
expect(drained.stdout).toBe("b".repeat(20_000));
expect(session.pendingStdout).toHaveLength(0);
expect(session.pendingStdoutChars).toBe(0);
expect(session.truncated).toBe(true);
});
it("respects max output cap when pending cap is larger", () => {
const session = createRegistrySession({
maxOutputChars: 5_000,
pendingMaxOutputChars: 30_000,
backgrounded: true,
});
addSession(session);
appendOutput(session, "stdout", "x".repeat(10_000));
const drained = drainSession(session);
expect(drained.stdout.length).toBe(5_000);
expect(session.truncated).toBe(true);
});
it("caps stdout and stderr independently", () => {
const session = createRegistrySession({
maxOutputChars: 100,
pendingMaxOutputChars: 10,
backgrounded: true,
});
addSession(session);
appendOutput(session, "stdout", "a".repeat(6));
appendOutput(session, "stdout", "b".repeat(6));
appendOutput(session, "stderr", "c".repeat(12));
const drained = drainSession(session);
expect(drained.stdout).toBe("a".repeat(4) + "b".repeat(6));
expect(drained.stderr).toBe("c".repeat(10));
expect(session.truncated).toBe(true);
});
it("only persists finished sessions when backgrounded", () => {
const session = createRegistrySession({
maxOutputChars: 100,
pendingMaxOutputChars: 30_000,
backgrounded: false,
});
addSession(session);
markExited(session, 0, null, "completed");
expect(listFinishedSessions()).toHaveLength(0);
markBackgrounded(session);
markExited(session, 0, null, "completed");
expect(listFinishedSessions()).toHaveLength(1);
});
});
describe("cursorKeyMode", () => {
function createRegistrySession(params: {
id?: string;
maxOutputChars: number;
pendingMaxOutputChars: number;
backgrounded: boolean;
cursorKeyMode?: ProcessSession["cursorKeyMode"];
}): ProcessSession {
return createProcessSessionFixture({
id: params.id ?? "sess",
command: "echo test",
child: { pid: 123, removeAllListeners: vi.fn() } as unknown as ChildProcessWithoutNullStreams,
maxOutputChars: params.maxOutputChars,
pendingMaxOutputChars: params.pendingMaxOutputChars,
backgrounded: params.backgrounded,
cursorKeyMode: params.cursorKeyMode,
});
}
it("session cursorKeyMode can start unknown", () => {
const session = createRegistrySession({
maxOutputChars: 100,
pendingMaxOutputChars: 30_000,
backgrounded: false,
cursorKeyMode: "unknown",
});
expect(session.cursorKeyMode).toBe("unknown");
});
it("session cursorKeyMode can be set to application", () => {
const session = createRegistrySession({
maxOutputChars: 100,
pendingMaxOutputChars: 30_000,
backgrounded: false,
});
session.cursorKeyMode = "application";
expect(session.cursorKeyMode).toBe("application");
});
it("session cursorKeyMode can be toggled between normal and application", () => {
const session = createRegistrySession({
maxOutputChars: 100,
pendingMaxOutputChars: 30_000,
backgrounded: false,
cursorKeyMode: "unknown",
});
expect(session.cursorKeyMode).toBe("unknown");
session.cursorKeyMode = "application";
expect(session.cursorKeyMode).toBe("application");
session.cursorKeyMode = "normal";
expect(session.cursorKeyMode).toBe("normal");
});
});