diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d4554136fc19..8ee93857c125 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -192,12 +192,13 @@ jobs: - name: Run cross-platform PTY service tests if: matrix.settings.run && matrix.settings.pty working-directory: packages/core - run: bun test test/kilocode/pty-platform.test.ts test/kilocode/pty-smoke.test.ts --timeout 60000 + run: bun test test/kilocode/pty-platform.test.ts --timeout 60000 - - name: Run cross-platform PTY route tests + - name: Run cross-platform PTY route and TUI smoke tests if: matrix.settings.run && matrix.settings.pty working-directory: packages/opencode run: | + bun test test/kilocode/pty-smoke.test.ts --timeout 60000 bun test test/server/httpapi-pty.test.ts --test-name-pattern "serves Agent Manager regular terminal" --timeout 60000 bun test test/server/httpapi-v2-pty.test.ts --test-name-pattern "serves Agent Manager script terminal" --timeout 60000 diff --git a/packages/core/src/kilocode/pty/smoke.ts b/packages/core/src/kilocode/pty/smoke.ts index f97c39d628b8..fa5a4e01bef0 100644 --- a/packages/core/src/kilocode/pty/smoke.ts +++ b/packages/core/src/kilocode/pty/smoke.ts @@ -1,99 +1,8 @@ import { Shell } from "../../shell" import { KiloPtyTermination } from "./termination" import { spawn } from "#pty" -import { mkdtemp, rm } from "node:fs/promises" -import os from "node:os" -import path from "node:path" -import { stripVTControlCharacters } from "node:util" const TIMEOUT = 15_000 -const OUTPUT_LIMIT = 20_000 -const DIAGNOSTIC = - /(?:TUI worker error\b|(?:^|[\r\n])\s*(?:panic|fatal(?: error)?|unhandled exception|uncaught exception)\b)/i - -export async function render(file: string, args: string[] = ["--pure"], timeout = 60_000) { - const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-pty-render-")) - const env: Record = {} - for (const key of ["PATH", "SystemRoot", "SYSTEMROOT", "ComSpec", "LANG", "LC_ALL", "LC_CTYPE", "LANGUAGE"]) { - const value = process.env[key] - if (value !== undefined) env[key] = value - } - Object.assign(env, { - TERM: "xterm-256color", - KILO_TERMINAL: "1", - KILO_TEST_HOME: dir, - KILO_NO_DAEMON: "1", - KILO_DISABLE_AUTOUPDATE: "1", - KILO_DISABLE_MODELS_FETCH: "1", - KILO_DISABLE_PROJECT_CONFIG: "1", - KILO_DISABLE_DEFAULT_PLUGINS: "1", - KILO_PURE: "1", - KILO_CONFIG_CONTENT: JSON.stringify({ enabled_providers: [], experimental: { openTelemetry: false } }), - KILO_AUTH_CONTENT: "{}", - HOME: dir, - USERPROFILE: dir, - APPDATA: path.join(dir, "AppData", "Roaming"), - LOCALAPPDATA: path.join(dir, "AppData", "Local"), - XDG_DATA_HOME: path.join(dir, ".local", "share"), - XDG_CACHE_HOME: path.join(dir, ".cache"), - XDG_CONFIG_HOME: path.join(dir, ".config"), - XDG_STATE_HOME: path.join(dir, ".local", "state"), - TMPDIR: dir, - TMP: dir, - TEMP: dir, - }) - - try { - const proc = spawn(file, args, { name: "xterm-256color", cwd: dir, env, cols: 100, rows: 40 }) - const state = { output: "", phase: "prompt" } - const ready = Promise.withResolvers() - const data = proc.onData((chunk) => { - const raw = state.output + chunk - const text = stripVTControlCharacters(raw) - state.output = raw.slice(-OUTPUT_LIMIT) - if (DIAGNOSTIC.test(text)) { - ready.reject(new Error(`TUI diagnostic during ${state.phase}: ${JSON.stringify(state.output)}`)) - return - } - const visible = text.replace(/[\r\n]/g, "") - if (state.phase === "prompt" && visible.includes("Ask anything...")) { - state.phase = "palette" - state.output = "" - try { - proc.write("\x10") - } catch (err) { - ready.reject(err) - } - return - } - if (state.phase === "palette" && visible.includes("Commands")) ready.resolve() - }) - const exit = proc.onExit((event) => { - ready.reject( - new Error( - `TUI exited during ${state.phase} (code ${event.exitCode}, signal ${event.signal ?? "none"}): ${JSON.stringify(state.output)}`, - ), - ) - }) - const timer = setTimeout( - () => - ready.reject( - new Error(`TUI timed out during ${state.phase} after ${timeout}ms: ${JSON.stringify(state.output)}`), - ), - timeout, - ) - try { - await ready.promise - } finally { - clearTimeout(timer) - data.dispose() - exit.dispose() - await KiloPtyTermination.terminate(proc) - } - } finally { - await rm(dir, { recursive: true, force: true }) - } -} export async function smoke() { const proc = spawn(Shell.preferred(), [], { diff --git a/packages/core/test/kilocode/pty-smoke.test.ts b/packages/core/test/kilocode/pty-smoke.test.ts deleted file mode 100644 index d3b50987e2b3..000000000000 --- a/packages/core/test/kilocode/pty-smoke.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { render } from "../../src/kilocode/pty/smoke" - -const run = (source: string, timeout = 3_000) => render(process.execPath, ["-e", source], timeout) - -describe("rendered PTY smoke", () => { - test("accepts chunked terminal redraws and a responsive command palette", async () => { - const source = [ - "if (process.stdin.isTTY && process.stdin.setRawMode) process.stdin.setRawMode(true)", - 'process.stdout.write("\\x1b[2J\\x1b[HAsk any" + "\\r\\n".repeat(39) + "\\x1b[1;8H")', - 'setTimeout(() => process.stdout.write("thing..."), 20)', - 'process.stdin.on("data", (data) => {', - ' if (!data.toString().includes("\\x10")) return', - ' process.stdout.write("\\x1b[2J\\x1b[HCom" + "\\r\\n".repeat(39) + "\\x1b[1;4H")', - ' setTimeout(() => process.stdout.write("mands"), 20)', - "})", - "setInterval(() => {}, 1000)", - ].join("\n") - - await run(source) - }) - - test("times out when output has no visible prompt", async () => { - const source = 'process.stdout.write("\\x1b[2J\\x1b[H\\x1b[?25l\\r\\n"); setInterval(() => {}, 1000)' - - await expect(run(source)).rejects.toThrow(/timed out during prompt/) - }) - - test("rejects a zero exit before the prompt", async () => { - const source = 'process.stdout.write("started"); setTimeout(() => process.exit(0), 20)' - - await expect(run(source)).rejects.toThrow(/exited during prompt \(code 0,/) - }) - - test("rejects a nonzero exit before the prompt", async () => { - const source = 'process.stdout.write("started"); setTimeout(() => process.exit(7), 20)' - - await expect(run(source)).rejects.toThrow(/exited during prompt \(code 7,/) - }) - - test("times out when the prompt ignores the palette key", async () => { - const source = 'process.stdout.write("\\x1b[2J\\x1b[HAsk anything..."); setInterval(() => {}, 1000)' - - await expect(run(source)).rejects.toThrow(/timed out during palette/) - }) - - test("rejects a TUI worker diagnostic before bundled source text", async () => { - const source = - 'process.stdout.write("rendered: TUI worker error Error: Ask anything...\\nconst source = \\\"Commands\\\"\\n"); setInterval(() => {}, 1000)' - - await expect(run(source)).rejects.toThrow(/TUI diagnostic during prompt/) - }) -}) diff --git a/packages/opencode/src/kilocode/cli/cmd/pty-smoke.ts b/packages/opencode/src/kilocode/cli/cmd/pty-smoke.ts index fa5c9cab53e4..7c33e9cf0124 100644 --- a/packages/opencode/src/kilocode/cli/cmd/pty-smoke.ts +++ b/packages/opencode/src/kilocode/cli/cmd/pty-smoke.ts @@ -1,4 +1,123 @@ import { cmd } from "@/cli/cmd/cmd" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { stripVTControlCharacters } from "node:util" +import { VtScreen } from "./tui/vt/vt-screen" + +const OUTPUT_LIMIT = 20_000 +const DIAGNOSTIC = + /(?:TUI worker error\b|(?:^|[\r\n])\s*(?:panic|fatal(?: error)?|unhandled exception|uncaught exception)\b)/i + +export async function render(file: string, args: string[] = ["--pure"], timeout = 60_000) { + const { spawn } = await import("@opencode-ai/core/pty/driver") + const { KiloPtyTermination } = await import("@opencode-ai/core/kilocode/pty/termination") + const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-pty-render-")) + const env: Record = {} + for (const key of ["PATH", "SystemRoot", "SYSTEMROOT", "ComSpec", "LANG", "LC_ALL", "LC_CTYPE", "LANGUAGE"]) { + const value = process.env[key] + if (value !== undefined) env[key] = value + } + Object.assign(env, { + TERM: "xterm-256color", + KILO_TERMINAL: "1", + KILO_TEST_HOME: dir, + KILO_NO_DAEMON: "1", + KILO_DISABLE_AUTOUPDATE: "1", + KILO_DISABLE_MODELS_FETCH: "1", + KILO_DISABLE_PROJECT_CONFIG: "1", + KILO_DISABLE_DEFAULT_PLUGINS: "1", + KILO_PURE: "1", + KILO_CONFIG_CONTENT: JSON.stringify({ enabled_providers: ["anthropic"], experimental: { openTelemetry: false } }), + KILO_AUTH_CONTENT: "{}", + ANTHROPIC_API_KEY: "dummy", + HOME: dir, + USERPROFILE: dir, + APPDATA: path.join(dir, "AppData", "Roaming"), + LOCALAPPDATA: path.join(dir, "AppData", "Local"), + XDG_DATA_HOME: path.join(dir, ".local", "share"), + XDG_CACHE_HOME: path.join(dir, ".cache"), + XDG_CONFIG_HOME: path.join(dir, ".config"), + XDG_STATE_HOME: path.join(dir, ".local", "state"), + TMPDIR: dir, + TMP: dir, + TEMP: dir, + }) + + try { + const cwd = path.join(dir, "project") + await mkdir(cwd) + const proc = spawn(file, args, { name: "xterm-256color", cwd, env, cols: 100, rows: 40 }) + const screen = new VtScreen(100, 40) + const state = { + output: "", + phase: "screen", + suffix: crypto.randomUUID().slice(0, 8), + prefix: crypto.randomUUID().slice(0, 8), + } + const ready = Promise.withResolvers() + const write = (value: string) => { + try { + proc.write(value) + } catch (err) { + ready.reject(err) + } + } + const probe = () => { + if (state.phase === "edit" || state.phase === "done" || !screen.text().trim()) return + state.phase = "input" + write(`\x05\x15${state.suffix}`) + } + const data = proc.onData((chunk) => { + const raw = state.output + chunk + state.output = raw.slice(-OUTPUT_LIMIT) + if (DIAGNOSTIC.test(stripVTControlCharacters(raw))) { + ready.reject(new Error(`TUI diagnostic during ${state.phase}: ${JSON.stringify(state.output)}`)) + return + } + screen.write(chunk) + const text = screen.text() + if (state.phase === "screen") return probe() + if (state.phase === "input" && text.includes(state.suffix)) { + state.phase = "edit" + write(`\x01${state.prefix}`) + return + } + if (state.phase === "edit" && text.includes(state.prefix + state.suffix)) { + state.phase = "done" + ready.resolve() + } + }) + const exit = proc.onExit((event) => { + ready.reject( + new Error( + `TUI exited during ${state.phase} (code ${event.exitCode}, signal ${event.signal ?? "none"}): ${JSON.stringify(state.output)}`, + ), + ) + }) + const retry = setInterval(probe, 1_000) + const timer = setTimeout( + () => + ready.reject( + new Error( + `TUI timed out during ${state.phase} after ${timeout}ms: screen=${JSON.stringify(screen.text())}, output=${JSON.stringify(state.output)}`, + ), + ), + timeout, + ) + try { + await ready.promise + } finally { + clearTimeout(timer) + clearInterval(retry) + data.dispose() + exit.dispose() + await KiloPtyTermination.terminate(proc) + } + } finally { + await rm(dir, { recursive: true, force: true }) + } +} export const PtySmokeCommand = cmd({ command: "__pty-smoke", @@ -7,7 +126,7 @@ export const PtySmokeCommand = cmd({ if (process.env.KILO_PTY_SMOKE !== "1") throw new Error("PTY smoke command is release-only") const { PtySmoke } = await import("@opencode-ai/core/kilocode/pty/smoke") await PtySmoke.smoke() - await PtySmoke.render(process.execPath) + await render(process.execPath) console.log("Compiled TUI startup smoke test passed") }, }) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/vt/vt-screen.ts b/packages/opencode/src/kilocode/cli/cmd/tui/vt/vt-screen.ts index 54054720552e..bb21beb14e12 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/vt/vt-screen.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/vt/vt-screen.ts @@ -61,6 +61,7 @@ export class VtScreen { private state: "ground" | "esc" | "csi" | "osc" | "osc-esc" = "ground" private params = "" private intermediate = "" + private bell = false constructor(cols = 80, rows = 24) { this.cols = Math.max(1, cols) @@ -229,8 +230,9 @@ export class VtScreen { this.intermediate = "" return } - if (ch === "]") { + if ("]PX^_".includes(ch)) { this.state = "osc" + this.bell = ch === "]" return } if (ch === "7") { @@ -271,7 +273,7 @@ export class VtScreen { } private osc(ch: string, code: number) { - if (code === 0x07) { + if ((this.bell && code === 0x07) || code === 0x18 || code === 0x1a || code === 0x9c) { this.state = "ground" return } diff --git a/packages/opencode/test/kilocode/pty-smoke.test.ts b/packages/opencode/test/kilocode/pty-smoke.test.ts new file mode 100644 index 000000000000..b601600e56c7 --- /dev/null +++ b/packages/opencode/test/kilocode/pty-smoke.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test" +import { render } from "../../src/kilocode/cli/cmd/pty-smoke" + +const run = (source: string, timeout = 3_000) => render(process.execPath, ["-e", source], timeout) +const raw = "process.stdin.setRawMode(true); process.stdin.resume();" +const idle = "setInterval(() => {}, 1000)" + +const editor = (delay: number, fps = 60) => ` + import { createCliRenderer, InputRenderable, TextRenderable } from ${JSON.stringify(import.meta.resolve("@opentui/core"))} + const renderer = await createCliRenderer({ exitOnCtrlC: false, maxFps: ${fps} }) + renderer.root.add(new TextRenderable(renderer, { id: "title", content: "A different startup screen" })) + const input = new InputRenderable(renderer, { id: "input", width: 40, placeholder: "Type here" }) + renderer.root.add(input) + setTimeout(() => input.focus(), ${delay}) +` + +describe("rendered PTY smoke", () => { + test.each([0, 1_200])("accepts a real renderer with input focus delayed by %dms", async (delay) => { + await run(editor(delay), 10_000) + }) + + test("preserves pending input when a redraw takes longer than the retry interval", async () => { + await run(editor(0, 0.5), 15_000) + }) + + test.each([ + ["silent process", ""], + ["capability queries", "\x1b[?1049h\x1b[6n\x1bP+q4d73\x1b\\\x1b[14t"], + ["erased output", "\x1b[HTransient text\x1b[2J\x1b[H"], + ])("rejects a live process with a blank screen: %s", async (_, output) => { + await expect(run(`${raw} process.stdout.write(${JSON.stringify(output)}); ${idle}`)).rejects.toThrow( + /timed out during screen/, + ) + }) + + test.each([0, 7])("rejects exit code %d before rendering", async (code) => { + await expect(run(`process.exit(${code})`)).rejects.toThrow(new RegExp(`exited during screen \\(code ${code},`)) + }) + + test("rejects a static screen that ignores input", async () => { + await expect(run(`${raw} process.stdout.write("Visible but frozen"); ${idle}`)).rejects.toThrow( + /timed out during input/, + ) + }) + + test.each([ + ["terminal echo", ""], + ["raw echo", `${raw} process.stdin.on("data", (data) => process.stdout.write(data));`], + ])("rejects %s without application editing", async (_, source) => { + await expect(run(`${source} process.stdout.write("Echo is not a TUI"); ${idle}`)).rejects.toThrow(/timed out/) + }) + + test.each(["\x1b]0;", "\x1bP", "\x1b_"])("rejects input echoed only inside control string %j", async (start) => { + const source = `${raw} + process.stdout.write("Visible but frozen"); + process.stdin.on("data", (data) => process.stdout.write(${JSON.stringify(start)} + data + "\\x1b\\\\")); + ${idle} + ` + await expect(run(source)).rejects.toThrow(/timed out during input/) + }) + + test("rejects input that is drawn and then erased", async () => { + const source = `${raw} + process.stdout.write("Visible but frozen"); + process.stdin.on("data", (data) => process.stdout.write(data + "\\x1b[2J\\x1b[H")); + ${idle} + ` + await expect(run(source)).rejects.toThrow(/timed out during input/) + }) + + test("rejects a TUI worker diagnostic", async () => { + await expect(run(`process.stdout.write("TUI worker error: failed to render"); ${idle}`)).rejects.toThrow( + /TUI diagnostic during screen/, + ) + }) +}) diff --git a/packages/opencode/test/kilocode/vt-screen.test.ts b/packages/opencode/test/kilocode/vt-screen.test.ts index c3a7a76878db..ead3de2f8ca9 100644 --- a/packages/opencode/test/kilocode/vt-screen.test.ts +++ b/packages/opencode/test/kilocode/vt-screen.test.ts @@ -123,6 +123,27 @@ describe("VtScreen", () => { expect(vt.lines()[0]).toBe("abc") }) + test.each(["]", "P", "X", "^", "_"])("keeps chunked %s control-string payloads off the screen", (start) => { + const vt = new VtScreen(20, 5) + for (const char of `a${ESC}${start}hidden${ESC}\\b`) vt.write(char) + expect(vt.text()).toBe("ab") + }) + + test("only OSC accepts BEL as a string terminator", () => { + const vt = new VtScreen(20, 5) + vt.write(`${ESC}Pquery\x07still hidden${ESC}\\${ESC}]0;title\x07visible`) + expect(vt.text()).toBe("visible") + }) + + test("reconstructs split cursor-addressed redraws without joining unrelated rows", () => { + const vt = new VtScreen(100, 40) + vt.write(`${CSI}Hleft${"\r\n".repeat(39)}${CSI}1;5`) + vt.write("Hright") + expect(vt.lines().at(0)).toBe("leftright") + vt.write(`${CSI}1;5H${CSI}K${CSI}2;1Hright`) + expect(vt.text()).not.toContain("leftright") + }) + test("cursor hide/show via private mode", () => { const vt = new VtScreen(10, 2) vt.write(CSI + "?25l")